From 241a8e1f3f483d7f9d330cac61693dd803014b43 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 25 Jul 2019 16:06:41 -0400 Subject: [PATCH 01/30] Editor: Update the store to use Core Data entities. --- .../developers/data/data-core-editor.md | 18 +- packages/core-data/src/selectors.js | 3 +- packages/editor/src/store/actions.js | 320 ++++-------------- packages/editor/src/store/defaults.js | 7 - packages/editor/src/store/reducer.js | 194 +---------- packages/editor/src/store/selectors.js | 217 ++++++------ .../src/utils/with-change-detection/README.md | 41 --- .../src/utils/with-change-detection/index.js | 55 --- .../utils/with-change-detection/test/index.js | 113 ------- .../editor/src/utils/with-history/README.md | 42 --- .../editor/src/utils/with-history/index.js | 141 -------- .../src/utils/with-history/test/index.js | 253 -------------- 12 files changed, 183 insertions(+), 1221 deletions(-) delete mode 100644 packages/editor/src/utils/with-change-detection/README.md delete mode 100644 packages/editor/src/utils/with-change-detection/index.js delete mode 100644 packages/editor/src/utils/with-change-detection/test/index.js delete mode 100644 packages/editor/src/utils/with-history/README.md delete mode 100644 packages/editor/src/utils/with-history/index.js delete mode 100644 packages/editor/src/utils/with-history/test/index.js diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 884d2cc3910eb..f59ceb52ea99e 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -192,6 +192,8 @@ _Related_ # **getBlocksForSerialization** +> **Deprecated** since Gutenberg 6.2.0. + Returns a set of blocks which are to be used in consideration of the post's generated save content. @@ -1041,10 +1043,6 @@ _Parameters_ - _edits_ `Object`: Post attributes to edit. -_Returns_ - -- `Object`: Action object. - # **enablePublishSidebar** Returns an action object used in signalling that the user has enabled the @@ -1178,10 +1176,6 @@ _Related_ Returns an action object used in signalling that undo history should restore last popped state. -_Returns_ - -- `Object`: Action object. - # **refreshPost** Action generator for handling refreshing the current post. @@ -1240,10 +1234,6 @@ _Parameters_ - _blocks_ `Array`: Block Array. - _options_ `?Object`: Optional options. -_Returns_ - -- `Object`: Action object - # **resetPost** Returns an action object used in signalling that the latest version of the @@ -1357,10 +1347,6 @@ Action generator for trashing the current post in the editor. Returns an action object used in signalling that undo history should pop. -_Returns_ - -- `Object`: Action object. - # **unlockPostSaving** Returns an action object used to signal that post saving is unlocked. diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 0f5cc1dd50780..c1ef693b75eb0 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -156,8 +156,7 @@ export function getEntityRecordEdits( state, kind, name, recordId ) { export const getEntityRecordNonTransientEdits = createSelector( ( state, kind, name, recordId ) => { const { transientEdits = {} } = getEntity( state, kind, name ); - const edits = - getEntityRecordEdits( state, kind, name, recordId ) || []; + const edits = getEntityRecordEdits( state, kind, name, recordId ) || []; return Object.keys( edits ).reduce( ( acc, key ) => { if ( ! transientEdits[ key ] ) { acc[ key ] = edits[ key ]; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index be98bc2ef8bc6..6bb1ff853c34a 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,8 +1,7 @@ /** * External dependencies */ -import { castArray, pick, mapValues, has } from 'lodash'; -import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; +import { has, castArray } from 'lodash'; /** * WordPress dependencies @@ -12,25 +11,22 @@ import { dispatch, select, apiFetch } from '@wordpress/data-controls'; import { parse, synchronizeBlocksWithTemplate, + serialize, + isUnmodifiedDefaultBlock, + getFreeformContentHandlerName, } from '@wordpress/blocks'; +import { removep } from '@wordpress/autop'; import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies */ -import { - getPostRawValue, -} from './reducer'; import { STORE_KEY, POST_UPDATE_TRANSACTION_ID, - SAVE_POST_NOTICE_ID, TRASH_POST_NOTICE_ID, - AUTOSAVE_PROPERTIES, } from './constants'; import { - getNotificationArgumentsForSaveSuccess, - getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; import { awaitNextStateChange, getRegistry } from './controls'; @@ -183,7 +179,7 @@ export function* setupEditor( post, edits, template ) { edits, template, }; - yield resetEditorBlocks( blocks ); + yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); yield* __experimentalSubscribeSources(); } @@ -295,73 +291,6 @@ export function* resetAutosave( newAutosave ) { 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, }; } @@ -402,13 +331,11 @@ export function setupEditorState( post ) { * * @param {Object} edits Post attributes to edit. * - * @return {Object} Action object. + * @yield {Object} Action object or control. */ -export function editPost( edits ) { - return { - type: 'EDIT_POST', - edits, - }; +export function* editPost( edits ) { + const { id, type } = yield select( 'core/editor', 'getCurrentPost' ); + yield dispatch( 'core', 'editEntityRecord', 'postType', type, id, edits ); } /** @@ -432,175 +359,17 @@ export function __experimentalOptimisticUpdatePost( edits ) { * @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, AUTOSAVE_PROPERTIES ); - } - - 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 select( - '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 __experimentalRequestPostUpdateStart( options ); + const postType = yield select( 'core/editor', 'getCurrentPostType' ); + const postId = yield select( 'core/editor', 'getCurrentPostId' ); yield dispatch( - STORE_KEY, - '__experimentalOptimisticUpdatePost', - toSend + 'core', + 'saveEditedEntityRecord', + 'postType', + postType, + postId, + options ); - - let path = `/wp/v2/${ postType.rest_base }/${ post.id }`; - let method = 'PUT'; - if ( isAutosave ) { - const currentUser = yield select( 'core', 'getCurrentUser' ); - const currentUserId = currentUser ? currentUser.id : undefined; - const autosavePost = yield select( 'core', 'getAutosave', post.type, post.id, currentUserId ); - const mappedAutosavePost = mapValues( pick( autosavePost, AUTOSAVE_PROPERTIES ), getPostRawValue ); - - // Ensure autosaves contain all expected fields, using autosave or - // post values as fallback if not otherwise included in edits. - toSend = { - ...pick( post, AUTOSAVE_PROPERTIES ), - ...mappedAutosavePost, - ...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, - } ); - - if ( isAutosave ) { - yield dispatch( 'core', 'receiveAutosaves', post.id, newPost ); - } else { - yield dispatch( STORE_KEY, 'resetPost', 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 - ); - } - } } /** @@ -698,19 +467,19 @@ export function* autosave( options ) { * Returns an action object used in signalling that undo history should * restore last popped state. * - * @return {Object} Action object. + * @yield {Object} Action object. */ -export function redo() { - return { type: 'REDO' }; +export function* redo() { + yield dispatch( 'core', 'redo' ); } /** * Returns an action object used in signalling that undo history should pop. * - * @return {Object} Action object. + * @yield {Object} Action object. */ -export function undo() { - return { type: 'UNDO' }; +export function* undo() { + yield dispatch( 'core', 'undo' ); } /** @@ -946,7 +715,7 @@ export function unlockPostSaving( lockName ) { * @param {Array} blocks Block Array. * @param {?Object} options Optional options. * - * @return {Object} Action object + * @yield {Object} Action object */ export function* resetEditorBlocks( blocks, options = {} ) { const lastBlockAttributesChange = yield select( 'core/block-editor', '__experimentalGetLastBlockAttributeChanges' ); @@ -985,11 +754,38 @@ export function* resetEditorBlocks( blocks, options = {} ) { yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) ); } - return { - type: 'RESET_EDITOR_BLOCKS', - blocks: yield* getBlocksWithSourcedAttributes( blocks ), - shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, - }; + const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) }; + + if ( options.__unstableShouldCreateUndoLevel !== false ) { + edits.content = ( () => { + let blocksForSerialization = edits.blocks; + + // A single unmodified default block is assumed to + // be equivalent to an empty post. + if ( + blocksForSerialization.length === 1 && + isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) + ) { + blocksForSerialization = []; + } + + let content = serialize( blocksForSerialization ); + + // For compatibility, treat a post consisting of a + // single freeform block as legacy content and apply + // pre-block-editor removep'd content formatting. + if ( + blocksForSerialization.length === 1 && + blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() + ) { + content = removep( content ); + } + + return content; + } )(); + } + + yield* editPost( edits ); } /* diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index 07c92803bd0a1..f158a4664dec7 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -8,13 +8,6 @@ export const PREFERENCES_DEFAULTS = { isPublishSidebarEnabled: true, }; -/** - * Default initial edits state. - * - * @type {Object} - */ -export const INITIAL_EDITS_DEFAULTS = {}; - /** * The default post editor settings * diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index ef6ad6fd798c0..241eb7d974c42 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -2,14 +2,7 @@ * External dependencies */ import optimist from 'redux-optimist'; -import { - flow, - reduce, - omit, - mapValues, - keys, - isEqual, -} from 'lodash'; +import { reduce, omit, keys, isEqual } from 'lodash'; /** * WordPress dependencies @@ -20,14 +13,7 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ -import { - PREFERENCES_DEFAULTS, - INITIAL_EDITS_DEFAULTS, - EDITOR_SETTINGS_DEFAULTS, -} from './defaults'; -import { EDIT_MERGE_PROPERTIES } from './constants'; -import withChangeDetection from '../utils/with-change-detection'; -import withHistory from '../utils/with-history'; +import { PREFERENCES_DEFAULTS, EDITOR_SETTINGS_DEFAULTS } from './defaults'; /** * Returns a post attribute value, flattening nested rendered content using its @@ -114,165 +100,23 @@ export function shouldOverwriteState( action, previousAction ) { return isUpdatingSamePostProperty( action, previousAction ); } -/** - * Undoable reducer returning the editor post state, including blocks parsed - * from current HTML markup. - * - * Handles the following state keys: - * - edits: an object describing changes to be made to the current post, in - * the format accepted by the WP REST API - * - blocks: post content blocks - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export const editor = flow( [ - combineReducers, - - withHistory( { - resetTypes: [ 'SETUP_EDITOR_STATE' ], - ignoreTypes: [ - 'RESET_POST', - 'UPDATE_POST', - ], - shouldOverwriteState, - } ), -] )( { - // Track whether changes exist, resetting at each post save. Relies on - // editor initialization firing post reset as an effect. - blocks: withChangeDetection( { - resetTypes: [ 'SETUP_EDITOR_STATE', 'REQUEST_POST_UPDATE_START' ], - } )( ( state = { value: [] }, action ) => { - switch ( action.type ) { - case 'RESET_EDITOR_BLOCKS': - if ( action.blocks === state.value ) { - return state; - } - return { value: action.blocks }; - } - - return state; - } ), - edits( state = {}, action ) { - switch ( action.type ) { - case 'EDIT_POST': - return reduce( action.edits, ( result, value, key ) => { - // Only assign into result if not already same value - if ( value !== state[ key ] ) { - result = getMutateSafeObject( state, result ); - - if ( EDIT_MERGE_PROPERTIES.has( key ) ) { - // Merge properties should assign to current value. - result[ key ] = { ...result[ key ], ...value }; - } else { - // Otherwise override. - result[ key ] = value; - } - } - - return result; - }, state ); - case 'UPDATE_POST': - case 'RESET_POST': - const getCanonicalValue = action.type === 'UPDATE_POST' ? - ( key ) => action.edits[ key ] : - ( key ) => getPostRawValue( action.post[ key ] ); - - return reduce( state, ( result, value, key ) => { - if ( ! isEqual( value, getCanonicalValue( key ) ) ) { - return result; - } - - result = getMutateSafeObject( state, result ); - delete result[ key ]; - return result; - }, state ); - case 'RESET_EDITOR_BLOCKS': - if ( 'content' in state ) { - return omit( state, 'content' ); - } - - return state; - } - - return state; - }, -} ); - -/** - * Reducer returning the initial edits state. With matching shape to that of - * `editor.edits`, the initial edits are those applied programmatically, are - * not considered in prompting the user for unsaved changes, and are included - * in (and reset by) the next save payload. - * - * @param {Object} state Current state. - * @param {Object} action Action object. - * - * @return {Object} Next state. - */ -export function initialEdits( state = INITIAL_EDITS_DEFAULTS, action ) { +export function postId( state = null, action ) { switch ( action.type ) { - case 'SETUP_EDITOR': - if ( ! action.edits ) { - break; - } - - return action.edits; - case 'SETUP_EDITOR_STATE': - if ( 'content' in state ) { - return omit( state, 'content' ); - } - - return state; - - case 'UPDATE_POST': - return reduce( action.edits, ( result, value, key ) => { - if ( ! result.hasOwnProperty( key ) ) { - return result; - } - - result = getMutateSafeObject( state, result ); - delete result[ key ]; - return result; - }, state ); - case 'RESET_POST': - return INITIAL_EDITS_DEFAULTS; + case 'UPDATE_POST': + return action.post.id; } return state; } -/** - * Reducer returning the last-known state of the current post, in the format - * returned by the WP REST API. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function currentPost( state = {}, action ) { +export function postType( state = null, action ) { switch ( action.type ) { case 'SETUP_EDITOR_STATE': case 'RESET_POST': case 'UPDATE_POST': - let post; - if ( action.post ) { - post = action.post; - } else if ( action.edits ) { - post = { - ...state, - ...action.edits, - }; - } else { - return state; - } - - return mapValues( post, getPostRawValue ); + return action.post.type; } return state; @@ -337,25 +181,6 @@ export function saving( state = {}, action ) { switch ( action.type ) { case 'REQUEST_POST_UPDATE_START': return { - requesting: true, - successful: false, - error: null, - options: action.options || {}, - }; - - case 'REQUEST_POST_UPDATE_SUCCESS': - return { - requesting: false, - successful: true, - error: null, - options: action.options || {}, - }; - - case 'REQUEST_POST_UPDATE_FAILURE': - return { - requesting: false, - successful: false, - error: action.error, options: action.options || {}, }; } @@ -587,9 +412,8 @@ export function editorSettings( state = EDITOR_SETTINGS_DEFAULTS, action ) { } export default optimist( combineReducers( { - editor, - initialEdits, - currentPost, + postId, + postType, preferences, saving, postLock, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index fa6843010ab70..bd90568055ede 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -16,13 +16,11 @@ import createSelector from 'rememo'; * WordPress dependencies */ import { - serialize, getFreeformContentHandlerName, getDefaultBlockName, isUnmodifiedDefaultBlock, } from '@wordpress/blocks'; import { isInTheFuture, getDate } from '@wordpress/date'; -import { removep } from '@wordpress/autop'; import { addQueryArgs } from '@wordpress/url'; import { createRegistrySelector } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; @@ -49,6 +47,15 @@ import { getPostRawValue } from './reducer'; */ const EMPTY_OBJECT = {}; +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation, as in a connected or + * other pure component which performs `shouldComponentUpdate` check on props. + * This should be used as a last resort, since the normalized data should be + * maintained by the reducer result in state. + */ +const EMPTY_ARRAY = []; + /** * Returns true if any past editor history snapshots exist, or false otherwise. * @@ -56,9 +63,9 @@ const EMPTY_OBJECT = {}; * * @return {boolean} Whether undo history exists. */ -export function hasEditorUndo( state ) { - return state.editor.past.length > 0; -} +export const hasEditorUndo = createRegistrySelector( ( select ) => () => { + return select( 'core' ).hasUndo(); +} ); /** * Returns true if any future editor history snapshots exist, or false @@ -68,9 +75,9 @@ export function hasEditorUndo( state ) { * * @return {boolean} Whether redo history exists. */ -export function hasEditorRedo( state ) { - return state.editor.future.length > 0; -} +export const hasEditorRedo = createRegistrySelector( ( select ) => () => { + return select( 'core' ).hasRedo(); +} ); /** * Returns true if the currently edited post is yet to be saved, or false if @@ -92,15 +99,17 @@ export function isEditedPostNew( state ) { * @return {boolean} Whether content includes unsaved changes. */ export function hasChangedContent( state ) { + const edits = getPostEdits( state ); + return ( - state.editor.present.blocks.isDirty || + 'blocks' in edits || // `edits` is intended to contain only values which are different from // the saved post, so the mere presence of a property is an indicator // that the value is different than what is known to be saved. While // content in Visual mode is represented by the blocks state, in Text // mode it is tracked by `edits.content`. - 'content' in state.editor.present.edits + 'content' in edits ); } @@ -112,25 +121,16 @@ export function hasChangedContent( state ) { * * @return {boolean} Whether unsaved values exist. */ -export function isEditedPostDirty( state ) { - if ( hasChangedContent( state ) ) { - return true; - } - +export const isEditedPostDirty = createRegistrySelector( ( select ) => ( state ) => { // Edits should contain only fields which differ from the saved post (reset // at initial load and save complete). Thus, a non-empty edits state can be // inferred to contain unsaved values. - if ( Object.keys( state.editor.present.edits ).length > 0 ) { + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + if ( select( 'core' ).hasEditsForEntityRecord( 'postType', postType, postId ) ) { return true; } - - // 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 - // chance that state may be reverted into one considered dirty. - return inSomeHistory( state, isEditedPostDirty ); -} +} ); /** * Returns true if there are no unsaved values for the current edit session and @@ -153,9 +153,20 @@ export function isCleanNewPost( state ) { * * @return {Object} Post object. */ -export function getCurrentPost( state ) { - return state.currentPost; -} +export const getCurrentPost = createRegistrySelector( ( select ) => ( state ) => { + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + + const post = select( 'core' ).getEntityRecord( 'postType', postType, postId ); + if ( post ) { + return post; + } + + // This exists for compatibility with the previous selector behavior + // which would guarantee an object return based on the editor reducer's + // default empty object state. + return EMPTY_OBJECT; +} ); /** * Returns the post type of the post currently being edited. @@ -165,7 +176,7 @@ export function getCurrentPost( state ) { * @return {string} Post type. */ export function getCurrentPostType( state ) { - return state.currentPost.type; + return state.postType; } /** @@ -177,7 +188,7 @@ export function getCurrentPostType( state ) { * @return {?number} ID of current post. */ export function getCurrentPostId( state ) { - return getCurrentPost( state ).id || null; + return state.postId; } /** @@ -211,18 +222,11 @@ export function getCurrentPostLastRevisionId( state ) { * * @return {Object} Object of key value pairs comprising unsaved edits. */ -export const getPostEdits = createSelector( - ( state ) => { - return { - ...state.initialEdits, - ...state.editor.present.edits, - }; - }, - ( state ) => [ - state.editor.present.edits, - state.initialEdits, - ] -); +export const getPostEdits = createRegistrySelector( ( select ) => ( state ) => { + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return select( 'core' ).getEntityRecordEdits( 'postType', postType, postId ) || EMPTY_OBJECT; +} ); /** * Returns a new reference when edited values have changed. This is useful in @@ -256,9 +260,20 @@ export const getReferenceByDistinctEdits = createSelector( * @return {*} Post attribute value. */ export function getCurrentPostAttribute( state, attributeName ) { - const post = getCurrentPost( state ); - if ( post.hasOwnProperty( attributeName ) ) { - return post[ attributeName ]; + switch ( attributeName ) { + case 'type': + return getCurrentPostType( state ); + + case 'id': + return getCurrentPostId( state ); + + default: + const post = getCurrentPost( state ); + if ( ! post.hasOwnProperty( attributeName ) ) { + break; + } + + return getPostRawValue( post[ attributeName ] ); } } @@ -272,23 +287,17 @@ export function getCurrentPostAttribute( state, attributeName ) { * * @return {*} Post attribute value. */ -const getNestedEditedPostProperty = createSelector( - ( state, attributeName ) => { - const edits = getPostEdits( state ); - if ( ! edits.hasOwnProperty( attributeName ) ) { - return getCurrentPostAttribute( state, attributeName ); - } +const getNestedEditedPostProperty = ( state, attributeName ) => { + const edits = getPostEdits( state ); + if ( ! edits.hasOwnProperty( attributeName ) ) { + return getCurrentPostAttribute( state, attributeName ); + } - return { - ...getCurrentPostAttribute( state, attributeName ), - ...edits[ attributeName ], - }; - }, - ( state, attributeName ) => [ - get( state.editor.present.edits, [ attributeName ], EMPTY_OBJECT ), - get( state.currentPost, [ attributeName ], EMPTY_OBJECT ), - ] -); + return { + ...getCurrentPostAttribute( state, attributeName ), + ...edits[ attributeName ], + }; +}; /** * Returns a single attribute of the post being edited, preferring the unsaved @@ -478,7 +487,7 @@ export function isEditedPostEmpty( state ) { // condition of the mere existence of blocks. Note that the value of edited // content takes precedent over block content, and must fall through to the // default logic. - const blocks = state.editor.present.blocks.value; + const blocks = getEditorBlocks( state ); if ( blocks.length && ! ( 'content' in getPostEdits( state ) ) ) { // Pierce the abstraction of the serializer in knowing that blocks are @@ -654,9 +663,11 @@ export function isEditedPostDateFloating( state ) { * * @return {boolean} Whether post is being saved. */ -export function isSavingPost( state ) { - return state.saving.requesting; -} +export const isSavingPost = createRegistrySelector( ( select ) => ( state ) => { + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return select( 'core' ).isSavingEntityRecord( 'postType', postType, postId ); +} ); /** * Returns true if a previous post save was attempted successfully, or false @@ -666,9 +677,13 @@ export function isSavingPost( state ) { * * @return {boolean} Whether the post was saved successfully. */ -export function didPostSaveRequestSucceed( state ) { - return state.saving.successful; -} +export const didPostSaveRequestSucceed = createRegistrySelector( + ( select ) => ( state ) => { + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return ! select( 'core' ).getLastEntitySaveError( 'postType', postType, postId ); + } +); /** * Returns true if a previous post save was attempted but failed, or false @@ -678,9 +693,13 @@ export function didPostSaveRequestSucceed( state ) { * * @return {boolean} Whether the post save failed. */ -export function didPostSaveRequestFail( state ) { - return !! state.saving.error; -} +export const didPostSaveRequestFail = createRegistrySelector( + ( select ) => ( state ) => { + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return !! select( 'core' ).getLastEntitySaveError( 'postType', postType, postId ); + } +); /** * Returns true if the post is autosaving, or false otherwise. @@ -690,7 +709,10 @@ export function didPostSaveRequestFail( state ) { * @return {boolean} Whether the post is autosaving. */ export function isAutosavingPost( state ) { - return isSavingPost( state ) && !! state.saving.options.isAutosave; + if ( ! isSavingPost( state ) ) { + return false; + } + return !! state.saving.options.isAutosave; } /** @@ -701,7 +723,10 @@ export function isAutosavingPost( state ) { * @return {boolean} Whether the post is being previewed. */ export function isPreviewingPost( state ) { - return isSavingPost( state ) && !! state.saving.options.isPreview; + if ( ! isSavingPost( state ) ) { + return false; + } + return !! state.saving.options.isPreview; } /** @@ -731,7 +756,7 @@ export function getEditedPostPreviewLink( state ) { * @return {?string} Suggested post format. */ export function getSuggestedPostFormat( state ) { - const blocks = state.editor.present.blocks.value; + const blocks = getEditorBlocks( state ); let name; // If there is only one block in the content of the post grab its name @@ -774,11 +799,19 @@ export function getSuggestedPostFormat( state ) { * Returns a set of blocks which are to be used in consideration of the post's * generated save content. * + * @deprecated since Gutenberg 6.2.0. + * * @param {Object} state Editor state. * * @return {WPBlock[]} Filtered set of blocks for save. */ export function getBlocksForSerialization( state ) { + deprecated( '`core/editor` getBlocksForSerialization selector', { + plugin: 'Gutenberg', + alternative: 'getEditorBlocks', + hint: 'Blocks serialization pre-processing occurs at save time', + } ); + const blocks = state.editor.present.blocks.value; // WARNING: Any changes to the logic of this function should be verified @@ -808,36 +841,12 @@ export function getBlocksForSerialization( state ) { * * @return {string} Post content. */ -export const getEditedPostContent = createSelector( - ( state ) => { - const edits = getPostEdits( state ); - if ( 'content' in edits ) { - return edits.content; - } - - const blocks = getBlocksForSerialization( state ); - const content = serialize( blocks ); - - // For compatibility purposes, treat a post consisting of a single - // freeform block as legacy content and downgrade to a pre-block-editor - // removep'd content format. - const isSingleFreeformBlock = ( - blocks.length === 1 && - blocks[ 0 ].name === getFreeformContentHandlerName() - ); - - if ( isSingleFreeformBlock ) { - return removep( content ); - } - - return content; - }, - ( state ) => [ - state.editor.present.blocks.value, - state.editor.present.edits.content, - state.initialEdits.content, - ], -); +export const getEditedPostContent = createRegistrySelector( ( select ) => ( state ) => { + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + const record = select( 'core' ).getEditedEntityRecord( 'postType', postType, postId ); + return record ? record.content : ''; +} ); /** * Returns the reusable block with the given ID. @@ -1130,7 +1139,7 @@ export function isPublishSidebarEnabled( state ) { * @return {Array} Block list. */ export function getEditorBlocks( state ) { - return state.editor.present.blocks.value; + return getEditedPostAttribute( state, 'blocks' ) || EMPTY_ARRAY; } /** diff --git a/packages/editor/src/utils/with-change-detection/README.md b/packages/editor/src/utils/with-change-detection/README.md deleted file mode 100644 index 75f6061483f83..0000000000000 --- a/packages/editor/src/utils/with-change-detection/README.md +++ /dev/null @@ -1,41 +0,0 @@ -withChangeDetection -=================== - -`withChangeDetection` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking changes to reducer state over time. - -It does this based on the following assumptions: - -- The original reducer returns an object -- The original reducer returns a new reference only if a change has in-fact occurred - -Using these assumptions, the enhanced reducer returned from `withChangeDetection` will include a new property on the object `isDirty` corresponding to whether the original reference of the reducer has ever changed. - -Leveraging a `resetTypes` option, this can be used to mark intervals at which a state is considered to be clean (without changes) and dirty (with changes). - -## Example - -Considering a simple count reducer, we can enhance it with `withChangeDetection` to reflect whether changes have occurred: - -```js -function counter( state = { count: 0 }, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { ...state, count: state.count + 1 }; - } - - return state; -} - -const enhancedCounter = withChangeDetection( counter, { resetTypes: [ 'RESET' ] } ); - -let state; - -state = enhancedCounter( undefined, {} ); -// { count: 0, isDirty: false } - -state = enhancedCounter( state, { type: 'INCREMENT' } ); -// { count: 1, isDirty: true } - -state = enhancedCounter( state, { type: 'RESET' } ); -// { count: 1, isDirty: false } -``` diff --git a/packages/editor/src/utils/with-change-detection/index.js b/packages/editor/src/utils/with-change-detection/index.js deleted file mode 100644 index e86db9a9905d4..0000000000000 --- a/packages/editor/src/utils/with-change-detection/index.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * External dependencies - */ -import { includes } from 'lodash'; - -/** - * Higher-order reducer creator for tracking changes to state over time. The - * returned reducer will include a `isDirty` property on the object reflecting - * whether the original reference of the reducer has changed. - * - * @param {?Object} options Optional options. - * @param {?Array} options.ignoreTypes Action types upon which to skip check. - * @param {?Array} options.resetTypes Action types upon which to reset dirty. - * - * @return {Function} Higher-order reducer. - */ -const withChangeDetection = ( options = {} ) => ( reducer ) => { - return ( state, action ) => { - let nextState = reducer( state, action ); - - // Reset at: - // - Initial state - // - Reset types - const isReset = ( - state === undefined || - includes( options.resetTypes, action.type ) - ); - - const isChanging = state !== nextState; - - // If not intending to update dirty flag, return early and avoid clone. - if ( ! isChanging && ! isReset ) { - return state; - } - - // Avoid mutating state, unless it's already changing by original - // reducer and not initial. - if ( ! isChanging || state === undefined ) { - nextState = { ...nextState }; - } - - const isIgnored = includes( options.ignoreTypes, action.type ); - - if ( isIgnored ) { - // Preserve the original value if ignored. - nextState.isDirty = state.isDirty; - } else { - nextState.isDirty = ! isReset && isChanging; - } - - return nextState; - }; -}; - -export default withChangeDetection; diff --git a/packages/editor/src/utils/with-change-detection/test/index.js b/packages/editor/src/utils/with-change-detection/test/index.js deleted file mode 100644 index 06abccb50fc5b..0000000000000 --- a/packages/editor/src/utils/with-change-detection/test/index.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * External dependencies - */ -import deepFreeze from 'deep-freeze'; - -/** - * Internal dependencies - */ -import withChangeDetection from '../'; - -describe( 'withChangeDetection()', () => { - const initialState = deepFreeze( { count: 0 } ); - - function originalReducer( state = initialState, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { - count: state.count + 1, - }; - - case 'RESET_AND_CHANGE_REFERENCE': - return { - count: state.count, - }; - } - - return state; - } - - it( 'should respect original reducer behavior', () => { - const reducer = withChangeDetection()( originalReducer ); - - const state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - const nextState = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( nextState ).not.toBe( state ); - expect( nextState ).toEqual( { count: 1, isDirty: true } ); - } ); - - it( 'should allow reset types as option', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - state = reducer( deepFreeze( state ), { type: 'RESET' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); - - it( 'should allow ignore types as option', () => { - const reducer = withChangeDetection( { ignoreTypes: [ 'INCREMENT' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); - - it( 'should preserve isDirty into non-resetting non-reference-changing types', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - const afterState = reducer( deepFreeze( state ), {} ); - expect( afterState ).toEqual( { count: 1, isDirty: true } ); - expect( afterState ).toBe( state ); - } ); - - it( 'should maintain separate states', () => { - const reducer = withChangeDetection()( originalReducer ); - - let firstState; - - firstState = reducer( undefined, {} ); - expect( firstState ).toEqual( { count: 0, isDirty: false } ); - - const secondState = reducer( undefined, { type: 'INCREMENT' } ); - expect( secondState ).toEqual( { count: 1, isDirty: false } ); - - firstState = reducer( deepFreeze( firstState ), {} ); - expect( firstState ).toEqual( { count: 0, isDirty: false } ); - } ); - - it( 'should flag as not dirty even if reset type causes reference change', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET_AND_CHANGE_REFERENCE' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - state = reducer( deepFreeze( state ), { type: 'RESET_AND_CHANGE_REFERENCE' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); -} ); diff --git a/packages/editor/src/utils/with-history/README.md b/packages/editor/src/utils/with-history/README.md deleted file mode 100644 index 3c90ed2d895be..0000000000000 --- a/packages/editor/src/utils/with-history/README.md +++ /dev/null @@ -1,42 +0,0 @@ -withHistory -=========== - -`withHistory` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking the history of a reducer state over time. The enhanced reducer returned from `withHistory` will return an object shape with properties `past`, `present`, and `future`. The `present` value maintains the current value of state returned from the original reducer. Past and future are respectively maintained as arrays of state values occurring previously and future (if history undone). - -Leveraging a `resetTypes` option, this can be used to mark intervals at which a state history should be reset, emptying the values of the `past` and `future` arrays. - -History can be adjusted by dispatching actions with type `UNDO` (reset to the previous state) and `REDO` (reset to the next state). - -## Example - -Considering a simple count reducer, we can enhance it with `withHistory` to track value over time: - -```js -function counter( state = { count: 0 }, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { ...state, count: state.count + 1 }; - } - - return state; -} - -const enhancedCounter = withHistory( counter, { resetTypes: [ 'RESET' ] } ); - -let state; - -state = enhancedCounter( undefined, {} ); -// { past: [], present: 0, future: [] } - -state = enhancedCounter( state, { type: 'INCREMENT' } ); -// { past: [ 0 ], present: 1, future: [] } - -state = enhancedCounter( state, { type: 'UNDO' } ); -// { past: [], present: 0, future: [ 1 ] } - -state = enhancedCounter( state, { type: 'REDO' } ); -// { past: [ 0 ], present: 1, future: [] } - -state = enhancedCounter( state, { type: 'RESET' } ); -// { past: [], present: 1, future: [] } -``` diff --git a/packages/editor/src/utils/with-history/index.js b/packages/editor/src/utils/with-history/index.js deleted file mode 100644 index 665851e632bd3..0000000000000 --- a/packages/editor/src/utils/with-history/index.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * External dependencies - */ -import { overSome, includes, first, last, drop, dropRight } from 'lodash'; - -/** - * Default options for withHistory reducer enhancer. Refer to withHistory - * documentation for options explanation. - * - * @see withHistory - * - * @type {Object} - */ -const DEFAULT_OPTIONS = { - resetTypes: [], - ignoreTypes: [], - shouldOverwriteState: () => false, -}; - -/** - * Higher-order reducer creator which transforms the result of the original - * reducer into an object tracking its own history (past, present, future). - * - * @param {?Object} options Optional options. - * @param {?Array} options.resetTypes Action types upon which to - * clear past. - * @param {?Array} options.ignoreTypes Action types upon which to - * avoid history tracking. - * @param {?Function} options.shouldOverwriteState Function receiving last and - * current actions, returning - * boolean indicating whether - * present should be merged, - * rather than add undo level. - * - * @return {Function} Higher-order reducer. - */ -const withHistory = ( options = {} ) => ( reducer ) => { - options = { ...DEFAULT_OPTIONS, ...options }; - - // `ignoreTypes` is simply a convenience for `shouldOverwriteState` - options.shouldOverwriteState = overSome( [ - options.shouldOverwriteState, - ( action ) => includes( options.ignoreTypes, action.type ), - ] ); - - const initialState = { - past: [], - present: reducer( undefined, {} ), - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - }; - - const { - resetTypes = [], - shouldOverwriteState = () => false, - } = options; - - return ( state = initialState, action ) => { - const { past, present, future, lastAction, shouldCreateUndoLevel } = state; - const previousAction = lastAction; - - switch ( action.type ) { - case 'UNDO': - // Can't undo if no past. - if ( ! past.length ) { - return state; - } - - return { - past: dropRight( past ), - present: last( past ), - future: [ present, ...future ], - lastAction: null, - shouldCreateUndoLevel: false, - }; - case 'REDO': - // Can't redo if no future. - if ( ! future.length ) { - return state; - } - - return { - past: [ ...past, present ], - present: first( future ), - future: drop( future ), - lastAction: null, - shouldCreateUndoLevel: false, - }; - - case 'CREATE_UNDO_LEVEL': - return { - ...state, - lastAction: null, - shouldCreateUndoLevel: true, - }; - } - - const nextPresent = reducer( present, action ); - - if ( includes( resetTypes, action.type ) ) { - return { - past: [], - present: nextPresent, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - }; - } - - if ( present === nextPresent ) { - return state; - } - - let nextPast = past; - // The `lastAction` property is used to compare actions in the - // `shouldOverwriteState` option. If an action should be ignored, do not - // submit that action as the last action, otherwise the ability to - // compare subsequent actions will break. - let lastActionToSubmit = previousAction; - - if ( - shouldCreateUndoLevel || - ! past.length || - ! shouldOverwriteState( action, previousAction ) - ) { - nextPast = [ ...past, present ]; - lastActionToSubmit = action; - } - - return { - past: nextPast, - present: nextPresent, - future: [], - shouldCreateUndoLevel: false, - lastAction: lastActionToSubmit, - }; - }; -}; - -export default withHistory; diff --git a/packages/editor/src/utils/with-history/test/index.js b/packages/editor/src/utils/with-history/test/index.js deleted file mode 100644 index e383988864f77..0000000000000 --- a/packages/editor/src/utils/with-history/test/index.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Internal dependencies - */ -import withHistory from '../'; - -describe( 'withHistory', () => { - const counter = ( state = 0, { type } ) => ( - type === 'INCREMENT' ? state + 1 : state - ); - - it( 'should return a new reducer', () => { - const reducer = withHistory()( counter ); - - expect( typeof reducer ).toBe( 'function' ); - expect( reducer( undefined, {} ) ).toEqual( { - past: [], - present: 0, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should track history', () => { - const reducer = withHistory()( counter ); - - let state; - const action = { type: 'INCREMENT' }; - state = reducer( undefined, {} ); - state = reducer( state, action ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: action, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, action ); - - expect( state ).toEqual( { - past: [ 0, 1 ], - present: 2, - future: [], - lastAction: action, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should perform undo', () => { - const reducer = withHistory()( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - - expect( state ).toEqual( { - past: [], - present: 0, - future: [ 1 ], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should not perform undo on empty past', () => { - const reducer = withHistory()( counter ); - const state = reducer( undefined, {} ); - - expect( state ).toBe( reducer( state, { type: 'UNDO' } ) ); - } ); - - it( 'should perform redo', () => { - const reducer = withHistory()( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - state = reducer( state, { type: 'REDO' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should not perform redo on empty future', () => { - const reducer = withHistory()( counter ); - const state = reducer( undefined, {} ); - - expect( state ).toBe( reducer( state, { type: 'REDO' } ) ); - } ); - - it( 'should reset history by options.resetTypes', () => { - const reducer = withHistory( { resetTypes: [ 'RESET_HISTORY' ] } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'RESET_HISTORY' } ); - - expect( state ).toEqual( { - past: [], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should ignore history by options.ignoreTypes', () => { - const reducer = withHistory( { ignoreTypes: [ 'INCREMENT' ] } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], // Needs at least one history - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should return same reference if state has not changed', () => { - const reducer = withHistory()( counter ); - const original = reducer( undefined, {} ); - const state = reducer( original, {} ); - - expect( state ).toBe( original ); - } ); - - it( 'should overwrite present state with option.shouldOverwriteState', () => { - const reducer = withHistory( { - shouldOverwriteState: ( { type } ) => type === 'INCREMENT', - } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should overwrite present state with option.shouldOverwriteState right after ignored action', () => { - const complexCounter = ( state = { count: 0 }, action ) => { - if ( action.type === 'INCREMENT' ) { - return { - ...state, - count: state.count + 1, - }; - } else if ( action.type === 'IGNORE' ) { - return { - ...state, - ignore: action.content, - }; - } - - return state; - }; - - const reducer = withHistory( { - shouldOverwriteState: ( action, previousAction ) => ( - previousAction && action.type === previousAction.type - ), - ignoreTypes: [ 'IGNORE' ], - } )( complexCounter ); - - let state = reducer( reducer( undefined, {} ), { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 1 }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'IGNORE', content: 'ignore' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 1, ignore: 'ignore' }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 2, ignore: 'ignore' }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should create undo level with option.shouldOverwriteState and CREATE_UNDO_LEVEL', () => { - const reducer = withHistory( { - shouldOverwriteState: ( { type } ) => type === 'INCREMENT', - } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'CREATE_UNDO_LEVEL' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: true, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0, 1 ], - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); -} ); From 3daf3a0a8135e099fae334fbab4464872d1001d0 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Tue, 6 Aug 2019 17:24:41 -0400 Subject: [PATCH 02/30] Editor: Fix selector test suites. --- packages/editor/src/store/actions.js | 59 +++-- packages/editor/src/store/selectors.js | 20 +- packages/editor/src/store/test/selectors.js | 257 ++++++++++++-------- 3 files changed, 198 insertions(+), 138 deletions(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 6bb1ff853c34a..0d3734af44090 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -709,6 +709,38 @@ export function unlockPostSaving( lockName ) { }; } +/** + * Serializes blocks following backwards compatibility conventions. + * + * @param {Array} blocksForSerialization The blocks to serialize. + * + * @return {string} The blocks serialization. + */ +export function serializeBlocks( blocksForSerialization ) { + // A single unmodified default block is assumed to + // be equivalent to an empty post. + if ( + blocksForSerialization.length === 1 && + isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) + ) { + blocksForSerialization = []; + } + + let content = serialize( blocksForSerialization ); + + // For compatibility, treat a post consisting of a + // single freeform block as legacy content and apply + // pre-block-editor removep'd content formatting. + if ( + blocksForSerialization.length === 1 && + blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() + ) { + content = removep( content ); + } + + return content; +} + /** * Returns an action object used to signal that the blocks have been updated. * @@ -757,32 +789,7 @@ export function* resetEditorBlocks( blocks, options = {} ) { const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) }; if ( options.__unstableShouldCreateUndoLevel !== false ) { - edits.content = ( () => { - let blocksForSerialization = edits.blocks; - - // A single unmodified default block is assumed to - // be equivalent to an empty post. - if ( - blocksForSerialization.length === 1 && - isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) - ) { - blocksForSerialization = []; - } - - let content = serialize( blocksForSerialization ); - - // For compatibility, treat a post consisting of a - // single freeform block as legacy content and apply - // pre-block-editor removep'd content formatting. - if ( - blocksForSerialization.length === 1 && - blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() - ) { - content = removep( content ); - } - - return content; - } )(); + edits.content = serializeBlocks( edits.blocks ); } yield* editPost( edits ); diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index bd90568055ede..5cec6084823b8 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -130,6 +130,7 @@ export const isEditedPostDirty = createRegistrySelector( ( select ) => ( state ) if ( select( 'core' ).hasEditsForEntityRecord( 'postType', postType, postId ) ) { return true; } + return false; } ); /** @@ -401,15 +402,19 @@ export function isCurrentPostPending( state ) { /** * Return true if the current post has already been published. * - * @param {Object} state Global application state. + * @param {Object} state Global application state. + * @param {Object?} currentPost Explicit current post for bypassing registry selector. * * @return {boolean} Whether the post has been published. */ -export function isCurrentPostPublished( state ) { - const post = getCurrentPost( state ); +export function isCurrentPostPublished( state, currentPost ) { + const post = currentPost || getCurrentPost( state ); - return [ 'publish', 'private' ].indexOf( post.status ) !== -1 || - ( post.status === 'future' && ! isInTheFuture( new Date( Number( getDate( post.date ) ) - ONE_MINUTE_IN_MS ) ) ); + return ( + [ 'publish', 'private' ].indexOf( post.status ) !== -1 || + ( post.status === 'future' && + ! isInTheFuture( new Date( Number( getDate( post.date ) ) - ONE_MINUTE_IN_MS ) ) ) + ); } /** @@ -965,7 +970,10 @@ export function isPublishingPost( state ) { // Consider as publishing when current post prior to request was not // considered published - return !! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest ); + return ( + !! stateBeforeRequest && + ! isCurrentPostPublished( null, stateBeforeRequest.currentPost ) + ); } /** diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 6d10b7f863aea..d11bc2350750f 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -20,10 +20,111 @@ import { RawHTML } from '@wordpress/element'; /** * Internal dependencies */ -import * as selectors from '../selectors'; +import { serializeBlocks } from '../actions'; +import * as _selectors from '../selectors'; import { PREFERENCES_DEFAULTS } from '../defaults'; import { POST_UPDATE_TRANSACTION_ID } from '../constants'; +const selectors = { ..._selectors }; +const selectorNames = Object.keys( selectors ); +selectorNames.forEach( ( name ) => { + selectors[ name ] = ( state, ...args ) => { + const select = () => ( { + getEntityRecord() { + return state.currentPost; + }, + + getEntityRecordEdits() { + const present = state.editor && state.editor.present; + let edits = present && present.edits; + + if ( state.initialEdits ) { + edits = { + ...state.initialEdits, + ...edits, + }; + } + + const { value: blocks, isDirty } = ( present && present.blocks ) || {}; + if ( blocks && isDirty !== false ) { + edits = { + ...edits, + blocks, + }; + } + + return edits; + }, + + hasEditsForEntityRecord() { + return Object.keys( this.getEntityRecordEdits() ).length > 0; + }, + + getEditedEntityRecord() { + let edits = this.getEntityRecordEdits(); + if ( edits.content === undefined && edits.blocks ) { + edits = { + ...edits, + content: serializeBlocks( edits.blocks ), + }; + } + return { + ...this.getEntityRecord(), + ...edits, + }; + }, + + isSavingEntityRecord() { + return state.saving && state.saving.requesting; + }, + + getLastEntitySaveError() { + const saving = state.saving; + const successful = saving && saving.successful; + const error = saving && saving.error; + return successful === undefined ? error : ! successful; + }, + + hasUndo() { + return Boolean( + state.editor && state.editor.past && state.editor.past.length + ); + }, + + hasRedo() { + return Boolean( + state.editor && state.editor.future && state.editor.future.length + ); + }, + + getCurrentUser() { + return state.getCurrentUser && state.getCurrentUser(); + }, + + hasFetchedAutosaves() { + return state.hasFetchedAutosaves && state.hasFetchedAutosaves(); + }, + + getAutosave() { + return state.getAutosave && state.getAutosave(); + }, + } ); + + selectorNames.forEach( ( otherName ) => { + if ( _selectors[ otherName ].isRegistrySelector ) { + _selectors[ otherName ].registry = { select }; + } + } ); + + return _selectors[ name ]( state, ...args ); + }; + selectors[ name ].isRegistrySelector = _selectors[ name ].isRegistrySelector; + if ( selectors[ name ].isRegistrySelector ) { + selectors[ name ].registry = { + select: () => _selectors[ name ].registry.select(), + }; + } +} ); const { hasEditorUndo, hasEditorRedo, @@ -44,7 +145,7 @@ const { isCurrentPostScheduled, isEditedPostPublishable, isEditedPostSaveable, - isEditedPostAutosaveable: _isEditedPostAutosaveableRegistrySelector, + isEditedPostAutosaveable, isEditedPostEmpty, isEditedPostBeingScheduled, isEditedPostDateFloating, @@ -71,14 +172,9 @@ const { describe( 'selectors', () => { let cachedSelectors; - let isEditedPostAutosaveableRegistrySelector; beforeAll( () => { cachedSelectors = filter( selectors, ( selector ) => selector.clear ); - isEditedPostAutosaveableRegistrySelector = ( select ) => { - _isEditedPostAutosaveableRegistrySelector.registry = { select }; - return _isEditedPostAutosaveableRegistrySelector; - }; } ); beforeEach( () => { @@ -354,37 +450,6 @@ describe( 'selectors', () => { expect( isEditedPostDirty( state ) ).toBe( true ); } ); - - it( 'should return true if pending transaction with dirty state', () => { - const state = { - optimist: [ - { - beforeState: { - editor: { - present: { - blocks: { - isDirty: true, - value: [], - }, - edits: {}, - }, - }, - }, - }, - ], - editor: { - present: { - blocks: { - isDirty: false, - value: [], - }, - edits: {}, - }, - }, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); } ); describe( 'isCleanNewPost', () => { @@ -471,7 +536,7 @@ describe( 'selectors', () => { describe( 'getCurrentPostId', () => { it( 'should return null if the post has not yet been saved', () => { const state = { - currentPost: {}, + postId: null, }; expect( getCurrentPostId( state ) ).toBeNull(); @@ -479,7 +544,7 @@ describe( 'selectors', () => { it( 'should return the current post ID', () => { const state = { - currentPost: { id: 1 }, + postId: 1, }; expect( getCurrentPostId( state ) ).toBe( 1 ); @@ -673,9 +738,7 @@ describe( 'selectors', () => { describe( 'getCurrentPostType', () => { it( 'should return the post type', () => { const state = { - currentPost: { - type: 'post', - }, + postType: 'post', }; expect( getCurrentPostType( state ) ).toBe( 'post' ); @@ -1272,18 +1335,6 @@ describe( 'selectors', () => { describe( 'isEditedPostAutosaveable', () => { it( 'should return false if existing autosaves have not yet been fetched', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return false; - }, - getAutosave() { - return { - title: 'sassel', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1300,24 +1351,21 @@ describe( 'selectors', () => { saving: { requesting: true, }, - }; - - expect( isEditedPostAutosaveable( state ) ).toBe( false ); - } ); - - it( 'should return false if the post is not saveable', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { getCurrentUser() {}, hasFetchedAutosaves() { - return true; + return false; }, getAutosave() { return { title: 'sassel', }; }, - } ) ); + }; + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + + it( 'should return false if the post is not saveable', () => { const state = { editor: { present: { @@ -1334,20 +1382,21 @@ describe( 'selectors', () => { saving: { requesting: true, }, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + title: 'sassel', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( false ); } ); it( 'should return true if there is no autosave', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() {}, - } ) ); - const state = { editor: { present: { @@ -1362,25 +1411,17 @@ describe( 'selectors', () => { title: 'sassel', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() {}, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); } ); it( 'should return false if none of title, excerpt, or content have changed', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() { - return { - title: 'foo', - excerpt: 'foo', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1397,13 +1438,6 @@ describe( 'selectors', () => { excerpt: 'foo', }, saving: {}, - }; - - expect( isEditedPostAutosaveable( state ) ).toBe( false ); - } ); - - it( 'should return true if content has changes', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { getCurrentUser() {}, hasFetchedAutosaves() { return true; @@ -1414,8 +1448,12 @@ describe( 'selectors', () => { excerpt: 'foo', }; }, - } ) ); + }; + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + + it( 'should return true if content has changes', () => { const state = { editor: { present: { @@ -1431,6 +1469,16 @@ describe( 'selectors', () => { excerpt: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + title: 'foo', + excerpt: 'foo', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); @@ -1439,19 +1487,6 @@ describe( 'selectors', () => { it( 'should return true if title or excerpt have changed', () => { for ( const variantField of [ 'title', 'excerpt' ] ) { for ( const constantField of without( [ 'title', 'excerpt' ], variantField ) ) { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() { - return { - [ constantField ]: 'foo', - [ variantField ]: 'bar', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1468,6 +1503,16 @@ describe( 'selectors', () => { content: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + [ constantField ]: 'foo', + [ variantField ]: 'bar', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); From 90d5e78d6797d6f42a74083dcd7007ca527a947e Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 7 Aug 2019 13:33:47 -0400 Subject: [PATCH 03/30] Editor: Fix some legacy selectors and behaviors. --- packages/core-data/src/reducer.js | 11 +++--- packages/editor/src/store/actions.js | 46 ++++++++++++++------------ packages/editor/src/store/selectors.js | 15 ++++++--- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 7f0972e19e481..780828786f357 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -310,8 +310,8 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { } // Transient edits don't create an undo level, but are - // reachable in the next meaningful edit to which they - // are merged. They are defined in the entity's config. + // added to the last level right before a new level + // is added. if ( ! Object.keys( action.edits ).some( ( key ) => ! action.transientEdits[ key ] ) ) { const nextState = [ ...state ]; nextState.flattenedUndo = { ...state.flattenedUndo, ...action.edits }; @@ -333,7 +333,10 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { } else { // Clear potential redos, because this only supports linear history. nextState = state.slice( 0, state.offset || undefined ); - nextState.flattenedUndo = state.flattenedUndo; + const lastItem = nextState[ nextState.length - 1 ]; + if ( lastItem ) { + lastItem.edits = { ...lastItem.edits, ...state.flattenedUndo }; + } } nextState.offset = 0; @@ -341,7 +344,7 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { kind: action.kind, name: action.name, recordId: action.recordId, - edits: { ...nextState.flattenedUndo, ...action.edits }, + edits: action.edits, } ); return nextState; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 0d3734af44090..de440ccdd271b 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -2,6 +2,7 @@ * External dependencies */ import { has, castArray } from 'lodash'; +import memoize from 'memize'; /** * WordPress dependencies @@ -716,30 +717,33 @@ export function unlockPostSaving( lockName ) { * * @return {string} The blocks serialization. */ -export function serializeBlocks( blocksForSerialization ) { - // A single unmodified default block is assumed to - // be equivalent to an empty post. - if ( - blocksForSerialization.length === 1 && - isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) - ) { - blocksForSerialization = []; - } +export const serializeBlocks = memoize( + ( blocksForSerialization ) => { + // A single unmodified default block is assumed to + // be equivalent to an empty post. + if ( + blocksForSerialization.length === 1 && + isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) + ) { + blocksForSerialization = []; + } - let content = serialize( blocksForSerialization ); + let content = serialize( blocksForSerialization ); - // For compatibility, treat a post consisting of a - // single freeform block as legacy content and apply - // pre-block-editor removep'd content formatting. - if ( - blocksForSerialization.length === 1 && - blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() - ) { - content = removep( content ); - } + // For compatibility, treat a post consisting of a + // single freeform block as legacy content and apply + // pre-block-editor removep'd content formatting. + if ( + blocksForSerialization.length === 1 && + blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() + ) { + content = removep( content ); + } - return content; -} + return content; + }, + { maxSize: 1 } +); /** * Returns an action object used to signal that the blocks have been updated. diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 5cec6084823b8..eea8780da473e 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -37,6 +37,7 @@ import { AUTOSAVE_PROPERTIES, } from './constants'; import { getPostRawValue } from './reducer'; +import { serializeBlocks } from './actions'; /** * Shared reference to an empty object for cases where it is important to avoid @@ -839,8 +840,7 @@ export function getBlocksForSerialization( state ) { } /** - * Returns the content of the post being edited, preferring raw string edit - * before falling back to serialization of block state. + * Returns the content of the post being edited. * * @param {Object} state Global application state. * @@ -849,8 +849,15 @@ export function getBlocksForSerialization( state ) { export const getEditedPostContent = createRegistrySelector( ( select ) => ( state ) => { const postId = getCurrentPostId( state ); const postType = getCurrentPostType( state ); - const record = select( 'core' ).getEditedEntityRecord( 'postType', postType, postId ); - return record ? record.content : ''; + const record = select( 'core' ).getEditedEntityRecord( + 'postType', + postType, + postId + ); + if ( record ) { + return record.blocks ? serializeBlocks( record.blocks ) : record.content || ''; + } + return ''; } ); /** From 4bd2fb1011922f7d801a8ae22ec907705ba9299a Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 7 Aug 2019 19:27:22 -0400 Subject: [PATCH 04/30] Editor: Fix action tests. --- packages/editor/src/store/test/actions.js | 475 +++------------------- 1 file changed, 66 insertions(+), 409 deletions(-) diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index ecb588c091223..0850bdea661e5 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; - /** * WordPress dependencies */ @@ -14,7 +9,6 @@ import { select, dispatch, apiFetch } from '@wordpress/data-controls'; import * as actions from '../actions'; import { STORE_KEY, - SAVE_POST_NOTICE_ID, TRASH_POST_NOTICE_ID, POST_UPDATE_TRANSACTION_ID, } from '../constants'; @@ -59,36 +53,14 @@ const postType = { }; const postId = 44; const postTypeSlug = 'post'; -const userId = 1; describe( 'Post generator actions', () => { describe( 'savePost()', () => { let fulfillment, - edits, currentPost, currentPostStatus, - currentUser, - editPostToSendOptimistic, - autoSavePost, - autoSavePostToSend, - savedPost, - savedPostStatus, - isAutosave, - isEditedPostNew, - savedPostMessage; + isAutosave; beforeEach( () => { - edits = ( defaultStatus = null ) => { - const postObject = { - title: 'foo', - content: 'bar', - excerpt: 'cheese', - foo: 'bar', - }; - if ( defaultStatus !== null ) { - postObject.status = defaultStatus; - } - return postObject; - }; currentPost = () => ( { id: postId, type: postTypeSlug, @@ -97,323 +69,65 @@ describe( 'Post generator actions', () => { excerpt: 'crackers', status: currentPostStatus, } ); - currentUser = { id: userId }; - 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(); - 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( currentUser ); - fulfillment.next(); - } else { - fulfillment.next(); - fulfillment.next(); - } - }; - const initialTestConditions = [ + const testConditions = [ [ - 'yields action for selecting if edited post is saveable', + 'yields an action for signalling that an update to the post started', () => 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' ) - ); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_START', + options: { isAutosave }, + } ); }, ], [ - 'yields action for selecting current post type slug', + 'yields an action for selecting the current post type', () => true, () => { - const { value } = fulfillment.next( editedPostContent ); + const { value } = fulfillment.next(); expect( value ).toEqual( select( STORE_KEY, 'getCurrentPostType' ) ); }, ], [ - 'yields action for selecting the post type object', + 'yields an action for selecting the current post ID', () => true, () => { - const { value } = fulfillment.next( postTypeSlug ); + const { value } = fulfillment.next( currentPost().type ); expect( value ).toEqual( - select( 'core', 'getPostType', postTypeSlug ) + select( STORE_KEY, 'getCurrentPostId' ) ); }, ], [ - 'yields action for dispatching request post update start', + 'yields an action for dispatching an update to the post entity', () => true, () => { - const { value } = fulfillment.next( postType ); + const { value } = fulfillment.next( currentPost().id ); expect( value ).toEqual( dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateStart', + 'core', + 'saveEditedEntityRecord', + 'postType', + currentPost().type, + currentPost().id, { isAutosave } ) ); }, ], [ - 'yields action for dispatching optimistic update of post', + 'implicitly returns undefined', () => 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' - ) - ); - }, - ], - [ - 'yields action for selecting the currentUser', - ( isAutosaving ) => isAutosaving, - () => { - const { value } = fulfillment.next(); - expect( value ).toEqual( - select( 'core', 'getCurrentUser' ) - ); - }, - ], - [ - 'yields action for selecting the autosavePost', - ( isAutosaving ) => isAutosaving, - () => { - const { value } = fulfillment.next( currentUser ); - expect( value ).toEqual( - select( - 'core', - 'getAutosave', - postTypeSlug, - postId, - userId - ) - ); - }, - ], - ]; - 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() ); - - if ( isAutosave ) { - expect( value ).toEqual( dispatch( 'core', 'receiveAutosaves', postId, savedPost() ) ); - } else { - expect( value ).toEqual( dispatch( STORE_KEY, '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', type: 'snackbar' }, - ] - ); - expect( value ).toEqual( expected ); + expect( fulfillment.next() ).toEqual( { done: true, value: undefined } ); }, ], ]; @@ -430,74 +144,28 @@ describe( 'Post generator actions', () => { } }; - 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', () => { + 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 ); } ); + testConditions.forEach( conditionalRunTestRoutine( false ) ); } ); 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 ); } ); + testConditions.forEach( conditionalRunTestRoutine( false ) ); } ); 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 ); } ); + testConditions.forEach( conditionalRunTestRoutine( true ) ); } ); } ); describe( 'autosave()', () => { @@ -663,7 +331,7 @@ describe( 'Editor actions', () => { } ); it( 'should yield action object for resetEditorBlocks', () => { const { value } = fulfillment.next(); - expect( value ).toEqual( actions.resetEditorBlocks( [] ) ); + expect( Object.keys( value ) ).toEqual( [] ); } ); it( 'should yield action object for setupEditorState', () => { const { value } = fulfillment.next(); @@ -691,47 +359,7 @@ describe( 'Editor actions', () => { const result = actions.__experimentalRequestPostUpdateStart(); expect( result ).toEqual( { type: 'REQUEST_POST_UPDATE_START', - optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, - options: {}, - } ); - } ); - } ); - - 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( '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 }, } ); } ); } ); @@ -748,11 +376,28 @@ describe( 'Editor actions', () => { } ); describe( 'editPost', () => { - it( 'should return EDIT_POST action', () => { + it( 'should edit the relevant entity record', () => { const edits = { format: 'sample' }; - expect( actions.editPost( edits ) ).toEqual( { - type: 'EDIT_POST', - edits, + const fulfillment = actions.editPost( edits ); + expect( fulfillment.next() ).toEqual( { + done: false, + value: select( STORE_KEY, 'getCurrentPost' ), + } ); + const post = { id: 1, type: 'post' }; + expect( fulfillment.next( post ) ).toEqual( { + done: false, + value: dispatch( + 'core', + 'editEntityRecord', + 'postType', + post.type, + post.id, + edits + ), + } ); + expect( fulfillment.next() ).toEqual( { + done: true, + value: undefined, } ); } ); } ); @@ -770,17 +415,29 @@ describe( 'Editor actions', () => { } ); describe( 'redo', () => { - it( 'should return REDO action', () => { - expect( actions.redo() ).toEqual( { - type: 'REDO', + it( 'should yield the REDO action', () => { + const fulfillment = actions.redo(); + expect( fulfillment.next() ).toEqual( { + done: false, + value: dispatch( 'core', 'redo' ), + } ); + expect( fulfillment.next() ).toEqual( { + done: true, + value: undefined, } ); } ); } ); describe( 'undo', () => { - it( 'should return UNDO action', () => { - expect( actions.undo() ).toEqual( { - type: 'UNDO', + it( 'should yield the UNDO action', () => { + const fulfillment = actions.undo(); + expect( fulfillment.next() ).toEqual( { + done: false, + value: dispatch( 'core', 'undo' ), + } ); + expect( fulfillment.next() ).toEqual( { + done: true, + value: undefined, } ); } ); } ); From db8bcab0c78d13905101b997d609cc3200929417 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 7 Aug 2019 19:55:11 -0400 Subject: [PATCH 05/30] Editor: Fix remaining broken unit tests. --- packages/editor/src/store/test/reducer.js | 361 +------------------- packages/editor/src/store/test/selectors.js | 12 +- 2 files changed, 8 insertions(+), 365 deletions(-) diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index d964c72c0f77a..3b31970aff30a 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -11,16 +11,12 @@ import { isUpdatingSamePostProperty, shouldOverwriteState, getPostRawValue, - initialEdits, - editor, - currentPost, preferences, saving, reusableBlocks, postSavingLock, previewLink, } from '../reducer'; -import { INITIAL_EDITS_DEFAULTS } from '../defaults'; describe( 'state', () => { describe( 'hasSameKeys()', () => { @@ -156,326 +152,6 @@ describe( 'state', () => { } ); } ); - describe( 'editor()', () => { - describe( 'blocks()', () => { - it( 'should set its value by RESET_EDITOR_BLOCKS', () => { - const blocks = [ { - clientId: 'block3', - innerBlocks: [ - { clientId: 'block31', innerBlocks: [] }, - { clientId: 'block32', innerBlocks: [] }, - ], - } ]; - const state = editor( undefined, { - type: 'RESET_EDITOR_BLOCKS', - blocks, - } ); - - expect( state.present.blocks.value ).toBe( blocks ); - } ); - } ); - - describe( 'edits()', () => { - it( 'should save newly edited properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - tags: [ 1 ], - }, - } ); - - expect( state.present.edits ).toEqual( { - status: 'draft', - title: 'post title', - tags: [ 1 ], - } ); - } ); - - it( 'should return same reference if no changed properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - status: 'draft', - }, - } ); - - expect( state.present.edits ).toBe( original.present.edits ); - } ); - - it( 'should save modified properties', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - status: 'draft', - title: 'post title', - tags: [ 1 ], - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - title: 'modified title', - tags: [ 2 ], - }, - } ); - - expect( state.present.edits ).toEqual( { - status: 'draft', - title: 'modified title', - tags: [ 2 ], - } ); - } ); - - it( 'should merge object values', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - meta: { - a: 1, - }, - }, - } ); - - const state = editor( original, { - type: 'EDIT_POST', - edits: { - meta: { - b: 2, - }, - }, - } ); - - expect( state.present.edits ).toEqual( { - meta: { - a: 1, - b: 2, - }, - } ); - } ); - - it( 'return state by reference on unchanging update', () => { - const original = editor( undefined, {} ); - - const state = editor( original, { - type: 'UPDATE_POST', - edits: {}, - } ); - - expect( state.present.edits ).toBe( original.present.edits ); - } ); - - it( 'unset reset post values which match by canonical value', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - title: 'modified title', - }, - } ); - - const state = editor( original, { - type: 'RESET_POST', - post: { - title: { - raw: 'modified title', - }, - }, - } ); - - expect( state.present.edits ).toEqual( {} ); - } ); - - it( 'unset reset post values by deep match', () => { - const original = editor( undefined, { - type: 'EDIT_POST', - edits: { - title: 'modified title', - meta: { - a: 1, - b: 2, - }, - }, - } ); - - const state = editor( original, { - type: 'UPDATE_POST', - edits: { - title: 'modified title', - meta: { - a: 1, - b: 2, - }, - }, - } ); - - expect( state.present.edits ).toEqual( {} ); - } ); - - it( 'should omit content when resetting', () => { - // Use case: When editing in Text mode, we defer to content on - // the property, but we reset blocks by parse when switching - // back to Visual mode. - const original = deepFreeze( editor( undefined, {} ) ); - let state = editor( original, { - type: 'EDIT_POST', - edits: { - content: 'bananas', - }, - } ); - - expect( state.present.edits ).toHaveProperty( 'content' ); - - state = editor( original, { - type: 'RESET_EDITOR_BLOCKS', - blocks: [ { - clientId: 'kumquat', - name: 'core/test-block', - attributes: {}, - innerBlocks: [], - }, { - clientId: 'loquat', - name: 'core/test-block', - attributes: {}, - innerBlocks: [], - } ], - } ); - - expect( state.present.edits ).not.toHaveProperty( 'content' ); - } ); - } ); - } ); - - describe( 'initialEdits', () => { - it( 'should default to initial edits', () => { - const state = initialEdits( undefined, {} ); - - expect( state ).toBe( INITIAL_EDITS_DEFAULTS ); - } ); - - it( 'should return initial edits on post reset', () => { - const state = initialEdits( undefined, { - type: 'RESET_POST', - } ); - - expect( state ).toBe( INITIAL_EDITS_DEFAULTS ); - } ); - - it( 'should return referentially equal state if setup includes no edits', () => { - const original = initialEdits( undefined, {} ); - const state = initialEdits( deepFreeze( original ), { - type: 'SETUP_EDITOR', - } ); - - expect( state ).toBe( original ); - } ); - - it( 'should return referentially equal state if reset while having made no edits', () => { - const original = initialEdits( undefined, {} ); - const state = initialEdits( deepFreeze( original ), { - type: 'RESET_POST', - } ); - - expect( state ).toBe( original ); - } ); - - it( 'should return setup edits', () => { - const original = initialEdits( undefined, {} ); - const state = initialEdits( deepFreeze( original ), { - type: 'SETUP_EDITOR', - edits: { - title: '', - content: '', - }, - } ); - - expect( state ).toEqual( { - title: '', - content: '', - } ); - } ); - - it( 'should unset content on editor setup', () => { - const original = initialEdits( undefined, { - type: 'SETUP_EDITOR', - edits: { - title: '', - content: '', - }, - } ); - const state = initialEdits( deepFreeze( original ), { - type: 'SETUP_EDITOR_STATE', - } ); - - expect( state ).toEqual( { title: '' } ); - } ); - - it( 'should unset values on post update', () => { - const original = initialEdits( undefined, { - type: 'SETUP_EDITOR', - edits: { - title: '', - }, - } ); - const state = initialEdits( deepFreeze( original ), { - type: 'UPDATE_POST', - edits: { - title: '', - }, - } ); - - expect( state ).toEqual( {} ); - } ); - } ); - - describe( 'currentPost()', () => { - it( 'should reset a post object', () => { - const original = deepFreeze( { title: 'unmodified' } ); - - const state = currentPost( original, { - type: 'RESET_POST', - post: { - title: 'new post', - }, - } ); - - expect( state ).toEqual( { - title: 'new post', - } ); - } ); - - it( 'should update the post object with UPDATE_POST', () => { - const original = deepFreeze( { title: 'unmodified', status: 'publish' } ); - - const state = currentPost( original, { - type: 'UPDATE_POST', - edits: { - title: 'updated post object from server', - }, - } ); - - expect( state ).toEqual( { - title: 'updated post object from server', - status: 'publish', - } ); - } ); - } ); - describe( 'preferences()', () => { it( 'should apply all defaults', () => { const state = preferences( undefined, {} ); @@ -508,43 +184,10 @@ describe( 'state', () => { it( 'should update when a request is started', () => { const state = saving( null, { type: 'REQUEST_POST_UPDATE_START', + options: { isAutosave: true }, } ); expect( state ).toEqual( { - requesting: true, - successful: false, - error: null, - options: {}, - } ); - } ); - - it( 'should update when a request succeeds', () => { - const state = saving( null, { - type: 'REQUEST_POST_UPDATE_SUCCESS', - } ); - expect( state ).toEqual( { - requesting: false, - successful: true, - error: null, - options: {}, - } ); - } ); - - it( 'should update when a request fails', () => { - const state = saving( null, { - type: 'REQUEST_POST_UPDATE_FAILURE', - error: { - code: 'pretend_error', - message: 'update failed', - }, - } ); - expect( state ).toEqual( { - requesting: false, - successful: false, - error: { - code: 'pretend_error', - message: 'update failed', - }, - options: {}, + options: { isAutosave: true }, } ); } ); } ); diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index d11bc2350750f..2185f04c80764 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -1591,7 +1591,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return true if blocks, but empty content edit', () => { + it( 'should return false if blocks, but empty content edit', () => { const state = { editor: { present: { @@ -1616,7 +1616,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostEmpty( state ) ).toBe( true ); + expect( isEditedPostEmpty( state ) ).toBe( false ); } ); it( 'should return true if the post has an empty content property', () => { @@ -1638,7 +1638,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return false if edits include a non-empty content property', () => { + it( 'should return true if edits include a non-empty content property, but blocks are empty', () => { const state = { editor: { present: { @@ -1654,7 +1654,7 @@ describe( 'selectors', () => { currentPost: {}, }; - expect( isEditedPostEmpty( state ) ).toBe( false ); + expect( isEditedPostEmpty( state ) ).toBe( true ); } ); it( 'should return true if empty classic block', () => { @@ -2147,7 +2147,7 @@ describe( 'selectors', () => { } ); } ); - it( 'defers to returning an edited post attribute', () => { + it( 'serializes blocks, if any', () => { const block = createBlock( 'core/block' ); const state = { @@ -2167,7 +2167,7 @@ describe( 'selectors', () => { const content = getEditedPostContent( state ); - expect( content ).toBe( 'custom edit' ); + expect( content ).toBe( '' ); } ); it( 'returns serialization of blocks', () => { From 0190c975c82637f7be4b6ad8f9945fb0537b12b3 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 8 Aug 2019 14:47:28 -0400 Subject: [PATCH 06/30] Editor: Fix more tests. --- .../developers/data/data-core-editor.md | 16 +++++++-- packages/core-data/src/actions.js | 31 +++++++++++----- packages/core-data/src/index.js | 3 +- packages/core-data/src/selectors.js | 29 +++++++++------ packages/editor/src/store/actions.js | 25 +++++++++++-- packages/editor/src/store/test/actions.js | 35 +++++++++++++++---- 6 files changed, 107 insertions(+), 32 deletions(-) diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index f59ceb52ea99e..82fffb3387cce 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -311,8 +311,7 @@ _Returns_ # **getEditedPostContent** -Returns the content of the post being edited, preferring raw string edit -before falling back to serialization of block state. +Returns the content of the post being edited. _Parameters_ @@ -742,6 +741,7 @@ Return true if the current post has already been published. _Parameters_ - _state_ `Object`: Global application state. +- _currentPost_ `?Object`: Explicit current post for bypassing registry selector. _Returns_ @@ -1261,6 +1261,18 @@ _Related_ - selectBlock in core/block-editor store. +# **serializeBlocks** + +Serializes blocks following backwards compatibility conventions. + +_Parameters_ + +- _blocksForSerialization_ `Array`: The blocks to serialize. + +_Returns_ + +- `string`: The blocks serialization. + # **setTemplateValidity** _Related_ diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 80d9fa1915b19..40183810dcd90 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,6 +3,11 @@ */ import { castArray, get, merge, isEqual, find } from 'lodash'; +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data-controls'; + /** * Internal dependencies */ @@ -147,7 +152,7 @@ export function* editEntityRecord( kind, name, recordId, edits ) { // Clear edits when they are equal to their persisted counterparts // so that the property is not considered dirty. edits: Object.keys( edits ).reduce( ( acc, key ) => { - const recordValue = get( record[ key ], 'raw', record[ key ] ); + const recordValue = record[ key ]; const value = mergedEdits[ key ] ? merge( recordValue, edits[ key ] ) : edits[ key ]; @@ -220,7 +225,7 @@ export function* saveEntityRecord( kind, name, record, - { isAutosave = false } = { isAutosave: false } + { isAutosave = false, getNoticeActionArgs } = { isAutosave: false } ) { const entities = yield getKindEntities( kind ); const entity = find( entities, { kind, name } ); @@ -235,13 +240,13 @@ export function* saveEntityRecord( let error; try { const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; + let persistedRecord; + let updatedRecord; + if ( isAutosave || getNoticeActionArgs ) { + persistedRecord = yield select( 'getEntityRecord', kind, name, recordId ); + } + if ( isAutosave ) { - const persistedRecord = yield select( - 'getEntityRecord', - kind, - name, - recordId - ); const currentUser = yield select( 'getCurrentUser' ); const currentUserId = currentUser ? currentUser.id : undefined; const autosavePost = yield select( @@ -275,6 +280,16 @@ export function* saveEntityRecord( } ); yield receiveEntityRecords( kind, name, updatedRecord, undefined, true ); } + + if ( getNoticeActionArgs ) { + yield dispatch( + ...getNoticeActionArgs( + persistedRecord, + updatedRecord, + yield select( 'getPostType', updatedRecord.type ) + ) + ); + } } catch ( _error ) { error = _error; } diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index b0c5718ab9b88..88d31a557e4be 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { registerStore } from '@wordpress/data'; +import { controls as dataControls } from '@wordpress/data-controls'; /** * Internal dependencies @@ -43,7 +44,7 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => { registerStore( REDUCER_KEY, { reducer, - controls, + controls: { ...dataControls, ...controls }, actions: { ...actions, ...entityActions }, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index c1ef693b75eb0..7b8e0ae451499 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -104,7 +104,20 @@ export function getEntity( state, kind, name ) { * @return {Object?} Record. */ export function getEntityRecord( state, kind, name, key ) { - return get( state.entities.data, [ kind, name, 'queriedData', 'items', key ] ); + const record = get( state.entities.data, [ + kind, + name, + 'queriedData', + 'items', + key, + ] ); + return ( + record && + Object.keys( record ).reduce( ( acc, _key ) => { + acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); + return acc; + }, {} ) + ); } /** @@ -193,16 +206,10 @@ export function hasEditsForEntityRecord( state, kind, name, recordId ) { * @return {Object?} The entity record, merged with its edits. */ export const getEditedEntityRecord = createSelector( - ( state, kind, name, recordId ) => { - const record = getEntityRecord( state, kind, name, recordId ); - return { - ...Object.keys( record ).reduce( ( acc, key ) => { - acc[ key ] = get( record[ key ], 'raw', record[ key ] ); - return acc; - }, {} ), - ...getEntityRecordEdits( state, kind, name, recordId ), - }; - }, + ( state, kind, name, recordId ) => ( { + ...getEntityRecord( state, kind, name, recordId ), + ...getEntityRecordEdits( state, kind, name, recordId ), + } ), ( state ) => [ state.entities.data ] ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index de440ccdd271b..85f68cc220b85 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -28,6 +28,7 @@ import { TRASH_POST_NOTICE_ID, } from './constants'; import { + getNotificationArgumentsForSaveSuccess, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; import { awaitNextStateChange, getRegistry } from './controls'; @@ -161,7 +162,7 @@ export function* setupEditor( post, edits, template ) { if ( has( edits, [ 'content' ] ) ) { content = edits.content; } else { - content = post.content.raw; + content = post.content; } let blocks = parse( content ); @@ -182,6 +183,9 @@ export function* setupEditor( post, edits, template ) { }; yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); + if ( edits ) { + yield editPost( edits ); + } yield* __experimentalSubscribeSources(); } @@ -239,7 +243,7 @@ export function* __experimentalSubscribeSources() { } if ( reset ) { - yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) ); + yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } ); } } } @@ -360,6 +364,9 @@ export function __experimentalOptimisticUpdatePost( edits ) { * @param {Object} options */ export function* savePost( options = {} ) { + yield dispatch( STORE_KEY, 'editPost', { + content: yield select( 'core/editor', 'getEditedPostContent' ), + } ); yield __experimentalRequestPostUpdateStart( options ); const postType = yield select( 'core/editor', 'getCurrentPostType' ); const postId = yield select( 'core/editor', 'getCurrentPostId' ); @@ -369,7 +376,19 @@ export function* savePost( options = {} ) { 'postType', postType, postId, - options + { + ...options, + getNoticeActionArgs: ( previousEntity, entity, type ) => [ + 'core/notices', + 'createSuccessNotice', + ...getNotificationArgumentsForSaveSuccess( { + previousPost: previousEntity, + post: entity, + postType: type, + options, + } ), + ], + } ); } diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 0850bdea661e5..0154f1fb80294 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -75,10 +75,31 @@ describe( 'Post generator actions', () => { ); const testConditions = [ [ - 'yields an action for signalling that an update to the post started', + 'yields an action for selecting the current edited post content', () => true, () => { reset( isAutosave ); + const { value } = fulfillment.next(); + expect( value ).toEqual( + select( STORE_KEY, 'getEditedPostContent' ) + ); + }, + ], + [ + "yields an action for editing the post entity's content", + () => true, + () => { + const edits = { content: currentPost().content }; + const { value } = fulfillment.next( edits.content ); + expect( value ).toEqual( + dispatch( STORE_KEY, 'editPost', edits ) + ); + }, + ], + [ + 'yields an action for signalling that an update to the post started', + () => true, + () => { const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'REQUEST_POST_UPDATE_START', @@ -111,6 +132,7 @@ describe( 'Post generator actions', () => { () => true, () => { const { value } = fulfillment.next( currentPost().id ); + value.args[ 3 ] = { ...value.args[ 3 ], getNoticeActionArgs: 'getNoticeActionArgs' }; expect( value ).toEqual( dispatch( 'core', @@ -118,7 +140,7 @@ describe( 'Post generator actions', () => { 'postType', currentPost().type, currentPost().id, - { isAutosave } + { isAutosave, getNoticeActionArgs: 'getNoticeActionArgs' } ) ); }, @@ -144,8 +166,7 @@ describe( 'Post generator actions', () => { } }; - describe( 'yields with expected responses for when not autosaving ' + - 'and edited post is new', () => { + describe( 'yields with expected responses for when not autosaving and edited post is new', () => { beforeEach( () => { isAutosave = false; currentPostStatus = 'draft'; @@ -305,7 +326,7 @@ describe( 'Post generator actions', () => { describe( 'Editor actions', () => { describe( 'setupEditor()', () => { - const post = { content: { raw: '' }, status: 'publish' }; + const post = { content: '', status: 'publish' }; let fulfillment; const reset = ( edits, template ) => fulfillment = actions @@ -326,7 +347,7 @@ describe( 'Editor actions', () => { const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'SETUP_EDITOR', - post: { content: { raw: '' }, status: 'publish' }, + post: { content: '', status: 'publish' }, } ); } ); it( 'should yield action object for resetEditorBlocks', () => { @@ -337,7 +358,7 @@ describe( 'Editor actions', () => { const { value } = fulfillment.next(); expect( value ).toEqual( actions.setupEditorState( - { content: { raw: '' }, status: 'publish' } + { content: '', status: 'publish' } ) ); } ); From 2cdd65d3410e679de6ac9fb838ddace6438b251d Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 8 Aug 2019 15:22:11 -0400 Subject: [PATCH 07/30] Editor: Fix more e2e test behaviors. --- packages/editor/src/store/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 85f68cc220b85..824eb8ad8091a 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -183,7 +183,7 @@ export function* setupEditor( post, edits, template ) { }; yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); - if ( edits ) { + if ( edits && Object.values( edits ).some( ( edit ) => edit ) ) { yield editPost( edits ); } yield* __experimentalSubscribeSources(); From d2f2320ad75e08fe212f54087f0bea09e870284d Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 8 Aug 2019 16:25:46 -0400 Subject: [PATCH 08/30] Editor: Fix preview functionality. --- packages/core-data/src/actions.js | 15 +++-- packages/editor/src/store/actions.js | 30 +++++++--- packages/editor/src/store/reducer.js | 34 +----------- packages/editor/src/store/selectors.js | 13 ++++- packages/editor/src/store/test/actions.js | 11 ++++ packages/editor/src/store/test/reducer.js | 67 +---------------------- 6 files changed, 58 insertions(+), 112 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 40183810dcd90..8acf52e816a30 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -282,13 +282,16 @@ export function* saveEntityRecord( } if ( getNoticeActionArgs ) { - yield dispatch( - ...getNoticeActionArgs( - persistedRecord, - updatedRecord, - yield select( 'getPostType', updatedRecord.type ) - ) + const args = getNoticeActionArgs( + persistedRecord, + updatedRecord, + yield select( 'getPostType', updatedRecord.type ) ); + if ( args && args.length ) { + yield dispatch( + ...args + ); + } } } catch ( _error ) { error = _error; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 824eb8ad8091a..bb57b9f99c6ca 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -287,7 +287,7 @@ export function* resetAutosave( newAutosave ) { } /** - * Optimistic action for dispatching that a post update request has started. + * Action for dispatching that a post update request has started. * * @param {Object} options * @@ -300,6 +300,20 @@ export function __experimentalRequestPostUpdateStart( options = {} ) { }; } +/** + * Action for dispatching that a post update request has finished. + * + * @param {Object} options + * + * @return {Object} An action object + */ +export function __experimentalRequestPostUpdateFinish( options = {} ) { + return { + type: 'REQUEST_POST_UPDATE_FINISH', + options, + }; +} + /** * Returns an action object used in signalling that a patch of updates for the * latest version of the post have been received. @@ -378,18 +392,20 @@ export function* savePost( options = {} ) { postId, { ...options, - getNoticeActionArgs: ( previousEntity, entity, type ) => [ - 'core/notices', - 'createSuccessNotice', - ...getNotificationArgumentsForSaveSuccess( { + getNoticeActionArgs: ( previousEntity, entity, type ) => { + const args = getNotificationArgumentsForSaveSuccess( { previousPost: previousEntity, post: entity, postType: type, options, - } ), - ], + } ); + if ( args && args.length ) { + return [ 'core/notices', 'createSuccessNotice', ...args ]; + } + }, } ); + yield __experimentalRequestPostUpdateFinish( options ); } /** diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 241eb7d974c42..a88e05f30ac77 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -8,7 +8,6 @@ import { reduce, omit, keys, isEqual } from 'lodash'; * WordPress dependencies */ import { combineReducers } from '@wordpress/data'; -import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -180,7 +179,9 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { export function saving( state = {}, action ) { switch ( action.type ) { case 'REQUEST_POST_UPDATE_START': + case 'REQUEST_POST_UPDATE_FINISH': return { + pending: action.type === 'REQUEST_POST_UPDATE_START', options: action.options || {}, }; } @@ -339,36 +340,6 @@ export const reusableBlocks = combineReducers( { }, } ); -/** - * Reducer returning the post preview link. - * - * @param {string?} state The preview link. - * @param {Object} action Dispatched action. - * - * @return {string?} Updated state. - */ -export function previewLink( state = null, action ) { - switch ( action.type ) { - case 'REQUEST_POST_UPDATE_SUCCESS': - if ( action.post.preview_link ) { - return action.post.preview_link; - } else if ( action.post.link ) { - return addQueryArgs( action.post.link, { preview: true } ); - } - - return state; - - case 'REQUEST_POST_UPDATE_START': - // Invalidate known preview link when autosave starts. - if ( state && action.options.isPreview ) { - return null; - } - break; - } - - return state; -} - /** * Reducer returning whether the editor is ready to be rendered. * The editor is considered ready to be rendered once @@ -419,7 +390,6 @@ export default optimist( combineReducers( { postLock, reusableBlocks, template, - previewLink, postSavingLock, isReady, editorSettings, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index eea8780da473e..c4bf57dfb2f68 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -743,8 +743,19 @@ export function isPreviewingPost( state ) { * @return {string?} Preview Link. */ export function getEditedPostPreviewLink( state ) { + if ( state.saving.pending || isSavingPost( state ) ) { + return; + } + + let previewLink = getEditedPostAttribute( state, 'preview_link' ); + if ( ! previewLink ) { + previewLink = getEditedPostAttribute( state, 'link' ); + if ( previewLink ) { + previewLink = addQueryArgs( previewLink, { preview: true } ); + } + } const featuredImageId = getEditedPostAttribute( state, 'featured_media' ); - const previewLink = state.previewLink; + if ( previewLink && featuredImageId ) { return addQueryArgs( previewLink, { _thumbnail_id: featuredImageId } ); } diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 0154f1fb80294..41b5cb772fc34 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -145,6 +145,17 @@ describe( 'Post generator actions', () => { ); }, ], + [ + 'yields an action for signalling that an update to the post finished', + () => true, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_FINISH', + options: { isAutosave }, + } ); + }, + ], [ 'implicitly returns undefined', () => true, diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index 3b31970aff30a..3fc6461b34e53 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -15,7 +15,6 @@ import { saving, reusableBlocks, postSavingLock, - previewLink, } from '../reducer'; describe( 'state', () => { @@ -187,6 +186,7 @@ describe( 'state', () => { options: { isAutosave: true }, } ); expect( state ).toEqual( { + pending: true, options: { isAutosave: true }, } ); } ); @@ -489,69 +489,4 @@ describe( 'state', () => { expect( state ).toEqual( {} ); } ); } ); - - describe( 'previewLink', () => { - it( 'returns null by default', () => { - const state = previewLink( undefined, {} ); - - expect( state ).toBe( null ); - } ); - - it( 'returns preview link from save success', () => { - const state = previewLink( null, { - type: 'REQUEST_POST_UPDATE_SUCCESS', - post: { - preview_link: 'https://example.com/?p=2611&preview=true', - }, - } ); - - expect( state ).toBe( 'https://example.com/?p=2611&preview=true' ); - } ); - - it( 'returns post link with query arg from save success if no preview link', () => { - const state = previewLink( null, { - type: 'REQUEST_POST_UPDATE_SUCCESS', - post: { - link: 'https://example.com/sample-post/', - }, - } ); - - expect( state ).toBe( 'https://example.com/sample-post/?preview=true' ); - } ); - - it( 'returns same state if save success without preview link or post link', () => { - // Bug: This can occur for post types which are defined as - // `publicly_queryable => false` (non-viewable). - // - // See: https://github.com/WordPress/gutenberg/issues/12677 - const state = previewLink( null, { - type: 'REQUEST_POST_UPDATE_SUCCESS', - post: { - preview_link: '', - }, - } ); - - expect( state ).toBe( null ); - } ); - - it( 'returns resets on preview start', () => { - const state = previewLink( 'https://example.com/sample-post/', { - type: 'REQUEST_POST_UPDATE_START', - options: { - isPreview: true, - }, - } ); - - expect( state ).toBe( null ); - } ); - - it( 'returns state on non-preview save start', () => { - const state = previewLink( 'https://example.com/sample-post/', { - type: 'REQUEST_POST_UPDATE_START', - options: {}, - } ); - - expect( state ).toBe( 'https://example.com/sample-post/' ); - } ); - } ); } ); From 24cb9658a1a93a6153c1e9d006dc7ff8a1838c6c Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 8 Aug 2019 19:05:14 -0400 Subject: [PATCH 09/30] Core Data: Fix autosaves filtering. --- packages/core-data/src/actions.js | 7 ++++--- packages/editor/src/store/selectors.js | 9 ++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 8acf52e816a30..5bf930ae30de0 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -261,7 +261,7 @@ export function* saveEntityRecord( // have a value. let data = { ...persistedRecord, ...autosavePost, ...record }; data = Object.keys( data ).reduce( ( acc, key ) => { - if ( key in [ 'title', 'excerpt', 'content' ] ) { + if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { acc[ key ] = get( data[ key ], 'raw', data[ key ] ); } return acc; @@ -282,10 +282,11 @@ export function* saveEntityRecord( } if ( getNoticeActionArgs ) { - const args = getNoticeActionArgs( + const postType = updatedRecord.type || persistedRecord.type; + const args = postType && getNoticeActionArgs( persistedRecord, updatedRecord, - yield select( 'getPostType', updatedRecord.type ) + yield select( 'getPostType', postType ) ); if ( args && args.length ) { yield dispatch( diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index c4bf57dfb2f68..003bd720861b9 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -347,12 +347,7 @@ export function getEditedPostAttribute( state, attributeName ) { * @return {*} Autosave attribute value. */ export const getAutosaveAttribute = createRegistrySelector( ( select ) => ( state, attributeName ) => { - deprecated( '`wp.data.select( \'core/editor\' ).getAutosaveAttribute( attributeName )`', { - alternative: '`wp.data.select( \'core\' ).getAutosave( postType, postId, userId )`', - plugin: 'Gutenberg', - } ); - - if ( ! includes( AUTOSAVE_PROPERTIES, attributeName ) ) { + if ( ! includes( AUTOSAVE_PROPERTIES, attributeName ) && attributeName !== 'preview_link' ) { return; } @@ -747,7 +742,7 @@ export function getEditedPostPreviewLink( state ) { return; } - let previewLink = getEditedPostAttribute( state, 'preview_link' ); + let previewLink = getAutosaveAttribute( state, 'preview_link' ); if ( ! previewLink ) { previewLink = getEditedPostAttribute( state, 'link' ); if ( previewLink ) { From eec16660bbc9ed411ae78374b2219b72500179ca Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 10:41:10 -0400 Subject: [PATCH 10/30] Editor: Don't make entity dirty with initial edits. --- packages/editor/src/store/actions.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index bb57b9f99c6ca..c6f21e119721c 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -183,8 +183,9 @@ export function* setupEditor( post, edits, template ) { }; yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); - if ( edits && Object.values( edits ).some( ( edit ) => edit ) ) { - yield editPost( edits ); + if ( edits ) { + const record = { ...post, ...edits }; + yield dispatch( 'core', 'receiveEntityRecords', 'postType', post.type, record ); } yield* __experimentalSubscribeSources(); } From b5f9d5fb64ec3224a43f398f144d845b7ff72c48 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 11:11:55 -0400 Subject: [PATCH 11/30] Editor: Don't save if the post is not saveable. --- packages/editor/src/store/actions.js | 4 ++++ packages/editor/src/store/test/actions.js | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index c6f21e119721c..53415c7a3de95 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -382,6 +382,10 @@ export function* savePost( options = {} ) { yield dispatch( STORE_KEY, 'editPost', { content: yield select( 'core/editor', 'getEditedPostContent' ), } ); + if ( ! ( yield select( STORE_KEY, 'isEditedPostSaveable' ) ) ) { + return; + } + yield __experimentalRequestPostUpdateStart( options ); const postType = yield select( 'core/editor', 'getCurrentPostType' ); const postId = yield select( 'core/editor', 'getCurrentPostId' ); diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 41b5cb772fc34..b4bea1523954e 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -97,10 +97,20 @@ describe( 'Post generator actions', () => { }, ], [ - 'yields an action for signalling that an update to the post started', + 'yields an action for checking if the post is saveable', () => true, () => { const { value } = fulfillment.next(); + expect( value ).toEqual( + select( STORE_KEY, 'isEditedPostSaveable' ) + ); + }, + ], + [ + 'yields an action for signalling that an update to the post started', + () => true, + () => { + const { value } = fulfillment.next( true ); expect( value ).toEqual( { type: 'REQUEST_POST_UPDATE_START', options: { isAutosave }, From 1d48cd82ad5d1eb3ab8fa4b189e33a98653ed9ec Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 11:33:08 -0400 Subject: [PATCH 12/30] Core Data: Fix merged edits logic. --- packages/core-data/src/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 5bf930ae30de0..7e37c3d5708c7 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -154,7 +154,7 @@ export function* editEntityRecord( kind, name, recordId, edits ) { edits: Object.keys( edits ).reduce( ( acc, key ) => { const recordValue = record[ key ]; const value = mergedEdits[ key ] ? - merge( recordValue, edits[ key ] ) : + merge( {}, recordValue, edits[ key ] ) : edits[ key ]; acc[ key ] = isEqual( recordValue, value ) ? undefined : value; return acc; From 1e91c9bbbae8103126564d6744c8652a4489eca1 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 12:28:10 -0400 Subject: [PATCH 13/30] Core Data: Fix undo to fit e2e expected behaviors. --- packages/core-data/src/reducer.js | 14 ++++++-------- packages/editor/src/store/actions.js | 6 +++--- packages/editor/src/store/test/actions.js | 20 ++++++++++---------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 780828786f357..5053e6c5c1aa3 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -330,21 +330,19 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { edits: { ...state.flattenedUndo, ...action.meta.undo.edits }, }, ]; - } else { - // Clear potential redos, because this only supports linear history. - nextState = state.slice( 0, state.offset || undefined ); - const lastItem = nextState[ nextState.length - 1 ]; - if ( lastItem ) { - lastItem.edits = { ...lastItem.edits, ...state.flattenedUndo }; - } + nextState.offset = 0; + return nextState; } + + // Clear potential redos, because this only supports linear history. + nextState = state.slice( 0, state.offset || undefined ); nextState.offset = 0; nextState.push( { kind: action.kind, name: action.name, recordId: action.recordId, - edits: action.edits, + edits: { ...action.edits, ...state.flattenedUndo }, } ); return nextState; diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 53415c7a3de95..aad6dd624c201 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -379,12 +379,12 @@ export function __experimentalOptimisticUpdatePost( edits ) { * @param {Object} options */ export function* savePost( options = {} ) { - yield dispatch( STORE_KEY, 'editPost', { - content: yield select( 'core/editor', 'getEditedPostContent' ), - } ); if ( ! ( yield select( STORE_KEY, 'isEditedPostSaveable' ) ) ) { return; } + yield dispatch( STORE_KEY, 'editPost', { + content: yield select( 'core/editor', 'getEditedPostContent' ), + } ); yield __experimentalRequestPostUpdateStart( options ); const postType = yield select( 'core/editor', 'getCurrentPostType' ); diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index b4bea1523954e..e6ebd5049aa9c 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -75,34 +75,34 @@ describe( 'Post generator actions', () => { ); const testConditions = [ [ - 'yields an action for selecting the current edited post content', + 'yields an action for checking if the post is saveable', () => true, () => { reset( isAutosave ); const { value } = fulfillment.next(); expect( value ).toEqual( - select( STORE_KEY, 'getEditedPostContent' ) + select( STORE_KEY, 'isEditedPostSaveable' ) ); }, ], [ - "yields an action for editing the post entity's content", + 'yields an action for selecting the current edited post content', () => true, () => { - const edits = { content: currentPost().content }; - const { value } = fulfillment.next( edits.content ); + const { value } = fulfillment.next( true ); expect( value ).toEqual( - dispatch( STORE_KEY, 'editPost', edits ) + select( STORE_KEY, 'getEditedPostContent' ) ); }, ], [ - 'yields an action for checking if the post is saveable', + "yields an action for editing the post entity's content", () => true, () => { - const { value } = fulfillment.next(); + const edits = { content: currentPost().content }; + const { value } = fulfillment.next( edits.content ); expect( value ).toEqual( - select( STORE_KEY, 'isEditedPostSaveable' ) + dispatch( STORE_KEY, 'editPost', edits ) ); }, ], @@ -110,7 +110,7 @@ describe( 'Post generator actions', () => { 'yields an action for signalling that an update to the post started', () => true, () => { - const { value } = fulfillment.next( true ); + const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'REQUEST_POST_UPDATE_START', options: { isAutosave }, From 16e69c8d162b74cfa00e098591e2d920c70150a9 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 14:26:04 -0400 Subject: [PATCH 14/30] Core Data: Handle more change detection and saving flows. --- .../src/components/block-list/block.js | 2 + packages/core-data/src/actions.js | 55 +++++++++++++------ packages/core-data/src/selectors.js | 7 +-- .../e2e-tests/specs/change-detection.test.js | 7 ++- packages/editor/src/store/actions.js | 13 ++++- packages/editor/src/store/selectors.js | 2 +- packages/editor/src/store/test/actions.js | 14 ++++- 7 files changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 96973f884f99a..5303254e7c56c 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -691,6 +691,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { replaceBlocks, toggleSelection, setNavigationMode, + __unstableMarkLastChangeAsPersistent, } = dispatch( 'core/block-editor' ); return { @@ -744,6 +745,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { } }, onReplace( blocks, indexToSelect ) { + __unstableMarkLastChangeAsPersistent(); replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect ); }, onShiftSelection() { diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 7e37c3d5708c7..031d764924108 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -225,7 +225,11 @@ export function* saveEntityRecord( kind, name, record, - { isAutosave = false, getNoticeActionArgs } = { isAutosave: false } + { + isAutosave = false, + getSuccessNoticeActionArgs, + getFailureNoticeActionArgs, + } = { isAutosave: false } ) { const entities = yield getKindEntities( kind ); const entity = find( entities, { kind, name } ); @@ -237,14 +241,13 @@ export function* saveEntityRecord( yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId, isAutosave }; let updatedRecord; + let persistedRecord; + if ( isAutosave || getSuccessNoticeActionArgs || getFailureNoticeActionArgs ) { + persistedRecord = yield select( 'getEntityRecord', kind, name, recordId ); + } let error; try { const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; - let persistedRecord; - let updatedRecord; - if ( isAutosave || getNoticeActionArgs ) { - persistedRecord = yield select( 'getEntityRecord', kind, name, recordId ); - } if ( isAutosave ) { const currentUser = yield select( 'getCurrentUser' ); @@ -271,7 +274,20 @@ export function* saveEntityRecord( method: 'POST', data, } ); - yield receiveAutosaves( persistedRecord.id, updatedRecord ); + // An autosave may be processed by the server as a regular save + // when its update is requested by the author and the post had + // draft or auto-draft status. + if ( persistedRecord.id === updatedRecord.id ) { + yield receiveEntityRecords( + kind, + name, + { ...persistedRecord, ...updatedRecord }, + undefined, + true + ); + } else { + yield receiveAutosaves( persistedRecord.id, updatedRecord ); + } } else { updatedRecord = yield apiFetch( { path, @@ -281,21 +297,28 @@ export function* saveEntityRecord( yield receiveEntityRecords( kind, name, updatedRecord, undefined, true ); } - if ( getNoticeActionArgs ) { + if ( getSuccessNoticeActionArgs ) { const postType = updatedRecord.type || persistedRecord.type; - const args = postType && getNoticeActionArgs( - persistedRecord, - updatedRecord, - yield select( 'getPostType', postType ) - ); - if ( args && args.length ) { - yield dispatch( - ...args + const args = + postType && + getSuccessNoticeActionArgs( + persistedRecord, + updatedRecord, + yield select( 'getPostType', postType ) ); + if ( args && args.length ) { + yield dispatch( ...args ); } } } catch ( _error ) { error = _error; + + if ( getFailureNoticeActionArgs ) { + const args = getFailureNoticeActionArgs( persistedRecord, record, error ); + if ( args && args.length ) { + yield dispatch( ...args ); + } + } } yield { type: 'SAVE_ENTITY_RECORD_FINISH', diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 7b8e0ae451499..7d140e199fdd6 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -243,12 +243,11 @@ export function isAutosavingEntityRecord( state, kind, name, recordId ) { * @return {boolean} Whether the entity record is saving or not. */ export function isSavingEntityRecord( state, kind, name, recordId ) { - const { pending, isAutosave } = get( + return get( state.entities.data, - [ kind, name, 'saving', recordId ], - {} + [ kind, name, 'saving', recordId, 'pending' ], + false ); - return Boolean( pending && ! isAutosave ); } /** diff --git a/packages/e2e-tests/specs/change-detection.test.js b/packages/e2e-tests/specs/change-detection.test.js index 30895ce5590de..8900df4b3ea0d 100644 --- a/packages/e2e-tests/specs/change-detection.test.js +++ b/packages/e2e-tests/specs/change-detection.test.js @@ -151,6 +151,7 @@ describe( 'Change detection', () => { it( 'Should prompt if content added without save', async () => { await clickBlockAppender(); + await page.keyboard.type( 'Paragraph' ); await assertIsDirty( true ); } ); @@ -223,9 +224,9 @@ describe( 'Change detection', () => { // Keyboard shortcut Ctrl+S save. await pressKeyWithModifier( 'primary', 'S' ); - await releaseSaveIntercept(); - await assertIsDirty( true ); + + await releaseSaveIntercept(); } ); it( 'Should prompt if changes made while save is in-flight', async () => { @@ -240,6 +241,7 @@ describe( 'Change detection', () => { await pressKeyWithModifier( 'primary', 'S' ); await page.type( '.editor-post-title__input', '!' ); + await page.waitForSelector( '.editor-post-save-draft' ); await releaseSaveIntercept(); @@ -279,6 +281,7 @@ describe( 'Change detection', () => { await pressKeyWithModifier( 'primary', 'S' ); await clickBlockAppender(); + await page.keyboard.type( 'Paragraph' ); // Allow save to complete. Disabling interception flushes pending. await Promise.all( [ diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index aad6dd624c201..30d577179c9cb 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -29,6 +29,7 @@ import { } from './constants'; import { getNotificationArgumentsForSaveSuccess, + getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; import { awaitNextStateChange, getRegistry } from './controls'; @@ -397,7 +398,7 @@ export function* savePost( options = {} ) { postId, { ...options, - getNoticeActionArgs: ( previousEntity, entity, type ) => { + getSuccessNoticeActionArgs: ( previousEntity, entity, type ) => { const args = getNotificationArgumentsForSaveSuccess( { previousPost: previousEntity, post: entity, @@ -408,6 +409,16 @@ export function* savePost( options = {} ) { return [ 'core/notices', 'createSuccessNotice', ...args ]; } }, + getFailureNoticeActionArgs: ( previousEntity, edits, error ) => { + const args = getNotificationArgumentsForSaveFail( { + post: previousEntity, + edits, + error, + } ); + if ( args && args.length ) { + return [ 'core/notices', 'createErrorNotice', ...args ]; + } + }, } ); yield __experimentalRequestPostUpdateFinish( options ); diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 003bd720861b9..10c2a0405ed6e 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -713,7 +713,7 @@ export function isAutosavingPost( state ) { if ( ! isSavingPost( state ) ) { return false; } - return !! state.saving.options.isAutosave; + return !! get( state.saving, [ 'options', 'isAutosave' ] ); } /** diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index e6ebd5049aa9c..f2940a0937c5d 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -142,7 +142,11 @@ describe( 'Post generator actions', () => { () => true, () => { const { value } = fulfillment.next( currentPost().id ); - value.args[ 3 ] = { ...value.args[ 3 ], getNoticeActionArgs: 'getNoticeActionArgs' }; + value.args[ 3 ] = { + ...value.args[ 3 ], + getSuccessNoticeActionArgs: 'getSuccessNoticeActionArgs', + getFailureNoticeActionArgs: 'getFailureNoticeActionArgs', + }; expect( value ).toEqual( dispatch( 'core', @@ -150,7 +154,13 @@ describe( 'Post generator actions', () => { 'postType', currentPost().type, currentPost().id, - { isAutosave, getNoticeActionArgs: 'getNoticeActionArgs' } + { + isAutosave, + getSuccessNoticeActionArgs: + 'getSuccessNoticeActionArgs', + getFailureNoticeActionArgs: + 'getFailureNoticeActionArgs', + } ) ); }, From d85aada1918d3732f67c19d974c7ebb25dd94d0d Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 16:47:18 -0400 Subject: [PATCH 15/30] Block Editor: Fix undo level logic. --- packages/block-editor/src/components/block-list/block.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 5303254e7c56c..ef3df798c1a50 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -745,7 +745,12 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { } }, onReplace( blocks, indexToSelect ) { - __unstableMarkLastChangeAsPersistent(); + if ( + blocks.length && + ! isUnmodifiedDefaultBlock( blocks[ blocks.length - 1 ] ) + ) { + __unstableMarkLastChangeAsPersistent(); + } replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect ); }, onShiftSelection() { From 8f70fbea51c9e907686d0b6af90145e26f2ddbe5 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Fri, 9 Aug 2019 17:14:06 -0400 Subject: [PATCH 16/30] Core Data: Clean up undo reducer comment. --- packages/core-data/src/reducer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 5053e6c5c1aa3..7bcf06afe1b85 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -310,8 +310,8 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { } // Transient edits don't create an undo level, but are - // added to the last level right before a new level - // is added. + // reachable in the next meaningful edit to which they + // are merged. They are defined in the entity's config. if ( ! Object.keys( action.edits ).some( ( key ) => ! action.transientEdits[ key ] ) ) { const nextState = [ ...state ]; nextState.flattenedUndo = { ...state.flattenedUndo, ...action.edits }; From e85a793d7a9fa57070b0837e135ab725caec8485 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Tue, 13 Aug 2019 11:39:44 -0700 Subject: [PATCH 17/30] Editor: Make `serializeBlocks` a util. --- .../developers/data/data-core-editor.md | 12 ----- packages/editor/src/store/actions.js | 46 +---------------- packages/editor/src/store/selectors.js | 2 +- packages/editor/src/store/test/selectors.js | 2 +- .../src/store/utils/serialize-blocks.js | 51 +++++++++++++++++++ 5 files changed, 55 insertions(+), 58 deletions(-) create mode 100644 packages/editor/src/store/utils/serialize-blocks.js diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 82fffb3387cce..905e9cc077847 100644 --- a/docs/designers-developers/developers/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -1261,18 +1261,6 @@ _Related_ - selectBlock in core/block-editor store. -# **serializeBlocks** - -Serializes blocks following backwards compatibility conventions. - -_Parameters_ - -- _blocksForSerialization_ `Array`: The blocks to serialize. - -_Returns_ - -- `string`: The blocks serialization. - # **setTemplateValidity** _Related_ diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 30d577179c9cb..f181e21e35764 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -2,21 +2,13 @@ * External dependencies */ import { has, castArray } from 'lodash'; -import memoize from 'memize'; /** * WordPress dependencies */ import deprecated from '@wordpress/deprecated'; import { dispatch, select, apiFetch } from '@wordpress/data-controls'; -import { - parse, - synchronizeBlocksWithTemplate, - serialize, - isUnmodifiedDefaultBlock, - getFreeformContentHandlerName, -} from '@wordpress/blocks'; -import { removep } from '@wordpress/autop'; +import { parse, synchronizeBlocksWithTemplate } from '@wordpress/blocks'; import isShallowEqual from '@wordpress/is-shallow-equal'; /** @@ -32,6 +24,7 @@ import { getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; +import serializeBlocks from './utils/serialize-blocks'; import { awaitNextStateChange, getRegistry } from './controls'; import * as sources from './block-sources'; @@ -761,41 +754,6 @@ export function unlockPostSaving( lockName ) { }; } -/** - * Serializes blocks following backwards compatibility conventions. - * - * @param {Array} blocksForSerialization The blocks to serialize. - * - * @return {string} The blocks serialization. - */ -export const serializeBlocks = memoize( - ( blocksForSerialization ) => { - // A single unmodified default block is assumed to - // be equivalent to an empty post. - if ( - blocksForSerialization.length === 1 && - isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) - ) { - blocksForSerialization = []; - } - - let content = serialize( blocksForSerialization ); - - // For compatibility, treat a post consisting of a - // single freeform block as legacy content and apply - // pre-block-editor removep'd content formatting. - if ( - blocksForSerialization.length === 1 && - blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() - ) { - content = removep( content ); - } - - return content; - }, - { maxSize: 1 } -); - /** * Returns an action object used to signal that the blocks have been updated. * diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 10c2a0405ed6e..00859bf4c567b 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -37,7 +37,7 @@ import { AUTOSAVE_PROPERTIES, } from './constants'; import { getPostRawValue } from './reducer'; -import { serializeBlocks } from './actions'; +import serializeBlocks from './utils/serialize-blocks'; /** * Shared reference to an empty object for cases where it is important to avoid diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 2185f04c80764..5151345cbe7b8 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -20,10 +20,10 @@ import { RawHTML } from '@wordpress/element'; /** * Internal dependencies */ -import { serializeBlocks } from '../actions'; import * as _selectors from '../selectors'; import { PREFERENCES_DEFAULTS } from '../defaults'; import { POST_UPDATE_TRANSACTION_ID } from '../constants'; +import serializeBlocks from '../utils/serialize-blocks'; const selectors = { ..._selectors }; const selectorNames = Object.keys( selectors ); diff --git a/packages/editor/src/store/utils/serialize-blocks.js b/packages/editor/src/store/utils/serialize-blocks.js new file mode 100644 index 0000000000000..7301350399ca5 --- /dev/null +++ b/packages/editor/src/store/utils/serialize-blocks.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import memoize from 'memize'; + +/** + * WordPress dependencies + */ +import { + isUnmodifiedDefaultBlock, + serialize, + getFreeformContentHandlerName, +} from '@wordpress/blocks'; +import { removep } from '@wordpress/autop'; + +/** + * Serializes blocks following backwards compatibility conventions. + * + * @param {Array} blocksForSerialization The blocks to serialize. + * + * @return {string} The blocks serialization. + */ +const serializeBlocks = memoize( + ( blocksForSerialization ) => { + // A single unmodified default block is assumed to + // be equivalent to an empty post. + if ( + blocksForSerialization.length === 1 && + isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) + ) { + blocksForSerialization = []; + } + + let content = serialize( blocksForSerialization ); + + // For compatibility, treat a post consisting of a + // single freeform block as legacy content and apply + // pre-block-editor removep'd content formatting. + if ( + blocksForSerialization.length === 1 && + blocksForSerialization[ 0 ].name === getFreeformContentHandlerName() + ) { + content = removep( content ); + } + + return content; + }, + { maxSize: 1 } +); + +export default serializeBlocks; From a3a7f5507ae81d39fd83918244cfc84984ab31c2 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 15 Aug 2019 15:10:15 -0700 Subject: [PATCH 18/30] Core Data: Clarify raw attribute usage. --- packages/core-data/src/actions.js | 1 + packages/core-data/src/reducer.js | 3 +++ packages/core-data/src/selectors.js | 3 +++ 3 files changed, 7 insertions(+) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 031d764924108..053dbcd40caf5 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -265,6 +265,7 @@ export function* saveEntityRecord( let data = { ...persistedRecord, ...autosavePost, ...record }; data = Object.keys( data ).reduce( ( acc, key ) => { if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { + // Edits should be the "raw" attribute values. acc[ key ] = get( data[ key ], 'raw', data[ key ] ); } return acc; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 7bcf06afe1b85..48c3a6da889e8 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -167,6 +167,9 @@ function entity( entityConfig ) { // If the edited value is still different to the persisted value, // keep the edited value in edits. if ( + // Edits are the "raw" attribute values, but records may have + // objects with more properties, so we use `get` here for the + // comparison. ! isEqual( edits[ key ], get( record[ key ], 'raw', record[ key ] ) ) ) { acc[ key ] = edits[ key ]; diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 7d140e199fdd6..f22aee52f3c33 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -114,6 +114,9 @@ export function getEntityRecord( state, kind, name, key ) { return ( record && Object.keys( record ).reduce( ( acc, _key ) => { + // Because edits are the "raw" attribute values, + // we return those from record selectors to make rendering, + // comparisons, and joins with edits easier. acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); return acc; }, {} ) From 3ef30160729a4048adec4efe70c770c865d648ad Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 15 Aug 2019 15:15:41 -0700 Subject: [PATCH 19/30] Core Data: Memoize . --- packages/core-data/src/selectors.js | 41 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index f22aee52f3c33..7e619333c4931 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -103,25 +103,28 @@ export function getEntity( state, kind, name ) { * * @return {Object?} Record. */ -export function getEntityRecord( state, kind, name, key ) { - const record = get( state.entities.data, [ - kind, - name, - 'queriedData', - 'items', - key, - ] ); - return ( - record && - Object.keys( record ).reduce( ( acc, _key ) => { - // Because edits are the "raw" attribute values, - // we return those from record selectors to make rendering, - // comparisons, and joins with edits easier. - acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); - return acc; - }, {} ) - ); -} +export const getEntityRecord = createSelector( + ( state, kind, name, key ) => { + const record = get( state.entities.data, [ + kind, + name, + 'queriedData', + 'items', + key, + ] ); + return ( + record && + Object.keys( record ).reduce( ( acc, _key ) => { + // Because edits are the "raw" attribute values, + // we return those from record selectors to make rendering, + // comparisons, and joins with edits easier. + acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); + return acc; + }, {} ) + ); + }, + ( state ) => [ state.entities.data ] +); /** * Returns the Entity's records. From 0e76e4cf78610bbf03950a4674f82d42daaf1168 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 10:56:37 -0700 Subject: [PATCH 20/30] Core Data: Use new raw entity record selector instead of modifying the existing one. --- .../developers/data/data-core.md | 16 ++++++++ packages/core-data/README.md | 16 ++++++++ packages/core-data/src/actions.js | 4 +- packages/core-data/src/selectors.js | 41 +++++++++++-------- packages/editor/src/store/selectors.js | 2 +- packages/editor/src/store/test/selectors.js | 4 +- 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index 90db77965f599..658ba7dc93021 100644 --- a/docs/designers-developers/developers/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -217,6 +217,22 @@ _Returns_ - `?Object`: The entity record's save error. +# **getRawEntityRecord** + +Returns the entity's record object by key, +with its attributes mapped to their raw values. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `number`: Record's key. + +_Returns_ + +- `?Object`: Object with the entity's raw attributes. + # **getRedoEdit** Returns the next edit from the current undo offset diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 64752e8ce030d..7d931ab20883e 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -428,6 +428,22 @@ _Returns_ - `?Object`: The entity record's save error. +# **getRawEntityRecord** + +Returns the entity's record object by key, +with its attributes mapped to their raw values. + +_Parameters_ + +- _state_ `Object`: State tree. +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `number`: Record's key. + +_Returns_ + +- `?Object`: Object with the entity's raw attributes. + # **getRedoEdit** Returns the next edit from the current undo offset diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 053dbcd40caf5..b3d10da0a40ca 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -137,7 +137,7 @@ export function* editEntityRecord( kind, name, recordId, edits ) { kind, name ); - const record = yield select( 'getEntityRecord', kind, name, recordId ); + const record = yield select( 'getRawEntityRecord', kind, name, recordId ); const editedRecord = yield select( 'getEditedEntityRecord', kind, @@ -243,7 +243,7 @@ export function* saveEntityRecord( let updatedRecord; let persistedRecord; if ( isAutosave || getSuccessNoticeActionArgs || getFailureNoticeActionArgs ) { - persistedRecord = yield select( 'getEntityRecord', kind, name, recordId ); + persistedRecord = yield select( 'getRawEntityRecord', kind, name, recordId ); } let error; try { diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 7e619333c4931..317b758748720 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -103,24 +103,33 @@ export function getEntity( state, kind, name ) { * * @return {Object?} Record. */ -export const getEntityRecord = createSelector( +export function getEntityRecord( state, kind, name, key ) { + return get( state.entities.data, [ kind, name, 'queriedData', 'items', key ] ); +} + +/** + * Returns the entity's record object by key, + * with its attributes mapped to their raw values. + * + * @param {Object} state State tree. + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {number} key Record's key. + * + * @return {Object?} Object with the entity's raw attributes. + */ +export const getRawEntityRecord = createSelector( ( state, kind, name, key ) => { - const record = get( state.entities.data, [ - kind, - name, - 'queriedData', - 'items', - key, - ] ); + const record = getEntityRecord( state, kind, name, key ); return ( record && - Object.keys( record ).reduce( ( acc, _key ) => { - // Because edits are the "raw" attribute values, - // we return those from record selectors to make rendering, - // comparisons, and joins with edits easier. - acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); - return acc; - }, {} ) + Object.keys( record ).reduce( ( acc, _key ) => { + // Because edits are the "raw" attribute values, + // we return those from record selectors to make rendering, + // comparisons, and joins with edits easier. + acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); + return acc; + }, {} ) ); }, ( state ) => [ state.entities.data ] @@ -213,7 +222,7 @@ export function hasEditsForEntityRecord( state, kind, name, recordId ) { */ export const getEditedEntityRecord = createSelector( ( state, kind, name, recordId ) => ( { - ...getEntityRecord( state, kind, name, recordId ), + ...getRawEntityRecord( state, kind, name, recordId ), ...getEntityRecordEdits( state, kind, name, recordId ), } ), ( state ) => [ state.entities.data ] diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 00859bf4c567b..e4d428b6b09cf 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -159,7 +159,7 @@ export const getCurrentPost = createRegistrySelector( ( select ) => ( state ) => const postId = getCurrentPostId( state ); const postType = getCurrentPostType( state ); - const post = select( 'core' ).getEntityRecord( 'postType', postType, postId ); + const post = select( 'core' ).getRawEntityRecord( 'postType', postType, postId ); if ( post ) { return post; } diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 5151345cbe7b8..f1e6039054265 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -30,7 +30,7 @@ const selectorNames = Object.keys( selectors ); selectorNames.forEach( ( name ) => { selectors[ name ] = ( state, ...args ) => { const select = () => ( { - getEntityRecord() { + getRawEntityRecord() { return state.currentPost; }, @@ -69,7 +69,7 @@ selectorNames.forEach( ( name ) => { }; } return { - ...this.getEntityRecord(), + ...this.getRawEntityRecord(), ...edits, }; }, From c271d866140cc500c9f5f9490ddd5224c2194cbc Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 12:15:20 -0700 Subject: [PATCH 21/30] Core Data: Make save notices the caller's responsibility. --- packages/core-data/src/actions.js | 37 +++----------- packages/editor/src/store/actions.js | 73 ++++++++++++++++------------ 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index b3d10da0a40ca..425cb82b65061 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -225,11 +225,7 @@ export function* saveEntityRecord( kind, name, record, - { - isAutosave = false, - getSuccessNoticeActionArgs, - getFailureNoticeActionArgs, - } = { isAutosave: false } + { isAutosave = false } = { isAutosave: false } ) { const entities = yield getKindEntities( kind ); const entity = find( entities, { kind, name } ); @@ -241,15 +237,17 @@ export function* saveEntityRecord( yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId, isAutosave }; let updatedRecord; - let persistedRecord; - if ( isAutosave || getSuccessNoticeActionArgs || getFailureNoticeActionArgs ) { - persistedRecord = yield select( 'getRawEntityRecord', kind, name, recordId ); - } let error; try { const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; if ( isAutosave ) { + const persistedRecord = yield select( + 'getRawEntityRecord', + kind, + name, + recordId + ); const currentUser = yield select( 'getCurrentUser' ); const currentUserId = currentUser ? currentUser.id : undefined; const autosavePost = yield select( @@ -297,29 +295,8 @@ export function* saveEntityRecord( } ); yield receiveEntityRecords( kind, name, updatedRecord, undefined, true ); } - - if ( getSuccessNoticeActionArgs ) { - const postType = updatedRecord.type || persistedRecord.type; - const args = - postType && - getSuccessNoticeActionArgs( - persistedRecord, - updatedRecord, - yield select( 'getPostType', postType ) - ); - if ( args && args.length ) { - yield dispatch( ...args ); - } - } } catch ( _error ) { error = _error; - - if ( getFailureNoticeActionArgs ) { - const args = getFailureNoticeActionArgs( persistedRecord, record, error ); - if ( args && args.length ) { - yield dispatch( ...args ); - } - } } yield { type: 'SAVE_ENTITY_RECORD_FINISH', diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index f181e21e35764..ab380058322d7 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -156,7 +156,7 @@ export function* setupEditor( post, edits, template ) { if ( has( edits, [ 'content' ] ) ) { content = edits.content; } else { - content = post.content; + content = post.content.raw || post.content; } let blocks = parse( content ); @@ -381,40 +381,51 @@ export function* savePost( options = {} ) { } ); yield __experimentalRequestPostUpdateStart( options ); - const postType = yield select( 'core/editor', 'getCurrentPostType' ); - const postId = yield select( 'core/editor', 'getCurrentPostId' ); + const previousRecord = yield select( 'core/editor', 'getCurrentPost' ); + const edits = { + id: previousRecord.id, + ...( yield select( + 'core', + 'getEntityRecordNonTransientEdits', + 'postType', + previousRecord.type, + previousRecord.id + ) ), + }; yield dispatch( 'core', - 'saveEditedEntityRecord', + 'saveEntityRecord', 'postType', - postType, - postId, - { - ...options, - getSuccessNoticeActionArgs: ( previousEntity, entity, type ) => { - const args = getNotificationArgumentsForSaveSuccess( { - previousPost: previousEntity, - post: entity, - postType: type, - options, - } ); - if ( args && args.length ) { - return [ 'core/notices', 'createSuccessNotice', ...args ]; - } - }, - getFailureNoticeActionArgs: ( previousEntity, edits, error ) => { - const args = getNotificationArgumentsForSaveFail( { - post: previousEntity, - edits, - error, - } ); - if ( args && args.length ) { - return [ 'core/notices', 'createErrorNotice', ...args ]; - } - }, - } + previousRecord.type, + edits, + options ); yield __experimentalRequestPostUpdateFinish( options ); + + const error = yield select( 'core', 'getLastEntitySaveError' ); + if ( error ) { + yield dispatch( + 'core/notices', + 'createErrorNotice', + ...getNotificationArgumentsForSaveFail( { + post: previousRecord, + edits, + error, + } ) + ); + } else { + const updatedRecord = yield select( 'core/editor', 'getCurrentPost' ); + yield dispatch( + 'core/notices', + 'createSuccessNotice', + ...getNotificationArgumentsForSaveSuccess( { + previousPost: previousRecord, + post: updatedRecord, + postType: yield select( 'core', 'getPostType', updatedRecord.type ), + options, + } ) + ); + } } /** @@ -692,7 +703,7 @@ export function disablePublishSidebar() { * @example * ``` * const { subscribe } = wp.data; - + * * const initialPostStatus = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); * * // Only allow publishing posts that are set to a future date. From 6c96372c0a27ab125932b938903853c606956191 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 12:16:19 -0700 Subject: [PATCH 22/30] Editor: Use the store key constant in actions instead of a string literal. --- packages/editor/src/store/actions.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index ab380058322d7..70b88f97f6119 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -206,7 +206,7 @@ export function* __experimentalSubscribeSources() { // The bailout case: If the editor becomes unmounted, it will flag // itself as non-ready. Effectively unsubscribes from the registry. - const isStillReady = yield select( 'core/editor', '__unstableIsEditorReady' ); + const isStillReady = yield select( STORE_KEY, '__unstableIsEditorReady' ); if ( ! isStillReady ) { break; } @@ -238,7 +238,7 @@ export function* __experimentalSubscribeSources() { } if ( reset ) { - yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } ); + yield resetEditorBlocks( yield select( STORE_KEY, 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } ); } } } @@ -348,7 +348,7 @@ export function setupEditorState( post ) { * @yield {Object} Action object or control. */ export function* editPost( edits ) { - const { id, type } = yield select( 'core/editor', 'getCurrentPost' ); + const { id, type } = yield select( STORE_KEY, 'getCurrentPost' ); yield dispatch( 'core', 'editEntityRecord', 'postType', type, id, edits ); } @@ -377,11 +377,11 @@ export function* savePost( options = {} ) { return; } yield dispatch( STORE_KEY, 'editPost', { - content: yield select( 'core/editor', 'getEditedPostContent' ), + content: yield select( STORE_KEY, 'getEditedPostContent' ), } ); yield __experimentalRequestPostUpdateStart( options ); - const previousRecord = yield select( 'core/editor', 'getCurrentPost' ); + const previousRecord = yield select( STORE_KEY, 'getCurrentPost' ); const edits = { id: previousRecord.id, ...( yield select( @@ -414,7 +414,7 @@ export function* savePost( options = {} ) { } ) ); } else { - const updatedRecord = yield select( 'core/editor', 'getCurrentPost' ); + const updatedRecord = yield select( STORE_KEY, 'getCurrentPost' ); yield dispatch( 'core/notices', 'createSuccessNotice', From 5cde8c8fb738f87bbae9338ee317f88af14d1fb1 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 16:54:22 -0700 Subject: [PATCH 23/30] Editor: Defer serialization of blocks until save. --- packages/core-data/src/actions.js | 5 - packages/core-data/src/index.js | 3 +- packages/editor/src/store/actions.js | 47 +++++---- packages/editor/src/store/selectors.js | 10 +- packages/editor/src/store/test/actions.js | 109 ++++++++++++++------ packages/editor/src/store/test/selectors.js | 10 +- 6 files changed, 115 insertions(+), 69 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 425cb82b65061..91f284959d796 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,11 +3,6 @@ */ import { castArray, get, merge, isEqual, find } from 'lodash'; -/** - * WordPress dependencies - */ -import { dispatch } from '@wordpress/data-controls'; - /** * Internal dependencies */ diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 88d31a557e4be..b0c5718ab9b88 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -2,7 +2,6 @@ * WordPress dependencies */ import { registerStore } from '@wordpress/data'; -import { controls as dataControls } from '@wordpress/data-controls'; /** * Internal dependencies @@ -44,7 +43,7 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => { registerStore( REDUCER_KEY, { reducer, - controls: { ...dataControls, ...controls }, + controls, actions: { ...actions, ...entityActions }, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 70b88f97f6119..71018a4d2dd11 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -402,29 +402,33 @@ export function* savePost( options = {} ) { ); yield __experimentalRequestPostUpdateFinish( options ); - const error = yield select( 'core', 'getLastEntitySaveError' ); + const error = yield select( + 'core', + 'getLastEntitySaveError', + 'postType', + previousRecord.type, + previousRecord.id + ); if ( error ) { - yield dispatch( - 'core/notices', - 'createErrorNotice', - ...getNotificationArgumentsForSaveFail( { - post: previousRecord, - edits, - error, - } ) - ); + const args = getNotificationArgumentsForSaveFail( { + post: previousRecord, + edits, + error, + } ); + if ( args.length ) { + yield dispatch( 'core/notices', 'createErrorNotice', ...args ); + } } else { const updatedRecord = yield select( STORE_KEY, 'getCurrentPost' ); - yield dispatch( - 'core/notices', - 'createSuccessNotice', - ...getNotificationArgumentsForSaveSuccess( { - previousPost: previousRecord, - post: updatedRecord, - postType: yield select( 'core', 'getPostType', updatedRecord.type ), - options, - } ) - ); + const args = getNotificationArgumentsForSaveSuccess( { + previousPost: previousRecord, + post: updatedRecord, + postType: yield select( 'core', 'getPostType', updatedRecord.type ), + options, + } ); + if ( args.length ) { + yield dispatch( 'core/notices', 'createSuccessNotice', ...args ); + } } } @@ -813,7 +817,8 @@ export function* resetEditorBlocks( blocks, options = {} ) { const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) }; if ( options.__unstableShouldCreateUndoLevel !== false ) { - edits.content = serializeBlocks( edits.blocks ); + edits.content = ( { blocks: blocksForSerialization = [] } ) => + serializeBlocks( blocksForSerialization ); } yield* editPost( edits ); diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index e4d428b6b09cf..daf7adbd9ee2a 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -490,7 +490,7 @@ export function isEditedPostEmpty( state ) { // default logic. const blocks = getEditorBlocks( state ); - if ( blocks.length && ! ( 'content' in getPostEdits( state ) ) ) { + if ( blocks.length ) { // Pierce the abstraction of the serializer in knowing that blocks are // joined with with newlines such that even if every individual block // produces an empty save result, the serialized content is non-empty. @@ -861,7 +861,13 @@ export const getEditedPostContent = createRegistrySelector( ( select ) => ( stat postId ); if ( record ) { - return record.blocks ? serializeBlocks( record.blocks ) : record.content || ''; + if ( typeof record.content === 'function' ) { + return record.content( record ); + } else if ( record.blocks ) { + return serializeBlocks( record.blocks ); + } else if ( record.content ) { + return record.content; + } } return ''; } ); diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index f2940a0937c5d..1045f34c7eac5 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -80,9 +80,7 @@ describe( 'Post generator actions', () => { () => { reset( isAutosave ); const { value } = fulfillment.next(); - expect( value ).toEqual( - select( STORE_KEY, 'isEditedPostSaveable' ) - ); + expect( value ).toEqual( select( STORE_KEY, 'isEditedPostSaveable' ) ); }, ], [ @@ -90,9 +88,7 @@ describe( 'Post generator actions', () => { () => true, () => { const { value } = fulfillment.next( true ); - expect( value ).toEqual( - select( STORE_KEY, 'getEditedPostContent' ) - ); + expect( value ).toEqual( select( STORE_KEY, 'getEditedPostContent' ) ); }, ], [ @@ -101,9 +97,7 @@ describe( 'Post generator actions', () => { () => { const edits = { content: currentPost().content }; const { value } = fulfillment.next( edits.content ); - expect( value ).toEqual( - dispatch( STORE_KEY, 'editPost', edits ) - ); + expect( value ).toEqual( dispatch( STORE_KEY, 'editPost', edits ) ); }, ], [ @@ -118,22 +112,27 @@ describe( 'Post generator actions', () => { }, ], [ - 'yields an action for selecting the current post type', + 'yields an action for selecting the current post', () => true, () => { const { value } = fulfillment.next(); - expect( value ).toEqual( - select( STORE_KEY, 'getCurrentPostType' ) - ); + expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) ); }, ], [ - 'yields an action for selecting the current post ID', + "yields an action for selecting the post entity's non transient edits", () => true, () => { - const { value } = fulfillment.next( currentPost().type ); + const post = currentPost(); + const { value } = fulfillment.next( post ); expect( value ).toEqual( - select( STORE_KEY, 'getCurrentPostId' ) + select( + 'core', + 'getEntityRecordNonTransientEdits', + 'postType', + post.type, + post.id + ) ); }, ], @@ -141,25 +140,17 @@ describe( 'Post generator actions', () => { 'yields an action for dispatching an update to the post entity', () => true, () => { - const { value } = fulfillment.next( currentPost().id ); - value.args[ 3 ] = { - ...value.args[ 3 ], - getSuccessNoticeActionArgs: 'getSuccessNoticeActionArgs', - getFailureNoticeActionArgs: 'getFailureNoticeActionArgs', - }; + const post = currentPost(); + const { value } = fulfillment.next( post ); expect( value ).toEqual( dispatch( 'core', - 'saveEditedEntityRecord', + 'saveEntityRecord', 'postType', - currentPost().type, - currentPost().id, + post.type, + post, { isAutosave, - getSuccessNoticeActionArgs: - 'getSuccessNoticeActionArgs', - getFailureNoticeActionArgs: - 'getFailureNoticeActionArgs', } ) ); @@ -176,11 +167,69 @@ describe( 'Post generator actions', () => { } ); }, ], + [ + "yields an action for selecting the entity's save error", + () => true, + () => { + const post = currentPost(); + const { value } = fulfillment.next(); + expect( value ).toEqual( + select( + 'core', + 'getLastEntitySaveError', + 'postType', + post.type, + post.id + ) + ); + }, + ], + [ + 'yields an action for selecting the current post', + () => true, + () => { + const { value } = fulfillment.next(); + expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) ); + }, + ], + [ + 'yields an action for selecting the current post type config', + () => true, + () => { + const post = currentPost(); + const { value } = fulfillment.next( post ); + expect( value ).toEqual( select( 'core', 'getPostType', post.type ) ); + }, + ], + [ + 'yields an action for dispatching a success notice', + () => true, + () => { + if ( ! isAutosave && currentPostStatus === 'publish' ) { + const { value } = fulfillment.next( postType ); + expect( value ).toEqual( + dispatch( + 'core/notices', + 'createSuccessNotice', + 'Updated Post', + { + actions: [], + id: 'SAVE_POST_NOTICE_ID', + type: 'snackbar', + } + ) + ); + } + }, + ], [ 'implicitly returns undefined', () => true, () => { - expect( fulfillment.next() ).toEqual( { done: true, value: undefined } ); + expect( fulfillment.next() ).toEqual( { + done: true, + value: undefined, + } ); }, ], ]; diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index f1e6039054265..73e0938ffccce 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -23,7 +23,6 @@ import { RawHTML } from '@wordpress/element'; import * as _selectors from '../selectors'; import { PREFERENCES_DEFAULTS } from '../defaults'; import { POST_UPDATE_TRANSACTION_ID } from '../constants'; -import serializeBlocks from '../utils/serialize-blocks'; const selectors = { ..._selectors }; const selectorNames = Object.keys( selectors ); @@ -61,16 +60,9 @@ selectorNames.forEach( ( name ) => { }, getEditedEntityRecord() { - let edits = this.getEntityRecordEdits(); - if ( edits.content === undefined && edits.blocks ) { - edits = { - ...edits, - content: serializeBlocks( edits.blocks ), - }; - } return { ...this.getRawEntityRecord(), - ...edits, + ...this.getEntityRecordEdits(), }; }, From f884aed44a8f1ad9566d16f2048a794e4ea2fa83 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 17:42:34 -0700 Subject: [PATCH 24/30] Editor: Fix raw content access in set up. --- packages/editor/src/store/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 71018a4d2dd11..c12fe93528c89 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -156,7 +156,7 @@ export function* setupEditor( post, edits, template ) { if ( has( edits, [ 'content' ] ) ) { content = edits.content; } else { - content = post.content.raw || post.content; + content = post.content.raw; } let blocks = parse( content ); From aeff970ed6c400338ef897fe67402e8687bf5e8c Mon Sep 17 00:00:00 2001 From: epiqueras Date: Mon, 19 Aug 2019 18:27:03 -0700 Subject: [PATCH 25/30] Editor: Revert broken test change. --- packages/editor/src/store/test/actions.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 1045f34c7eac5..cdc354dca9346 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -406,7 +406,7 @@ describe( 'Post generator actions', () => { describe( 'Editor actions', () => { describe( 'setupEditor()', () => { - const post = { content: '', status: 'publish' }; + const post = { content: { raw: '' }, status: 'publish' }; let fulfillment; const reset = ( edits, template ) => fulfillment = actions @@ -427,7 +427,7 @@ describe( 'Editor actions', () => { const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'SETUP_EDITOR', - post: { content: '', status: 'publish' }, + post: { content: { raw: '' }, status: 'publish' }, } ); } ); it( 'should yield action object for resetEditorBlocks', () => { @@ -437,9 +437,10 @@ describe( 'Editor actions', () => { it( 'should yield action object for setupEditorState', () => { const { value } = fulfillment.next(); expect( value ).toEqual( - actions.setupEditorState( - { content: '', status: 'publish' } - ) + actions.setupEditorState( { + content: { raw: '' }, + status: 'publish', + } ) ); } ); } ); From af246805b6bbf1807fe6114b15f8ec96722412ce Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 21 Aug 2019 11:53:55 -0700 Subject: [PATCH 26/30] Editor: Make initial edits a dirtying operation. --- packages/e2e-tests/specs/change-detection.test.js | 4 ++-- packages/editor/src/store/actions.js | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/specs/change-detection.test.js b/packages/e2e-tests/specs/change-detection.test.js index 8900df4b3ea0d..85f1b5dd92162 100644 --- a/packages/e2e-tests/specs/change-detection.test.js +++ b/packages/e2e-tests/specs/change-detection.test.js @@ -133,14 +133,14 @@ describe( 'Change detection', () => { await assertIsDirty( false ); } ); - it( 'Should not prompt to confirm unsaved changes for new post with initial edits', async () => { + it( 'Should prompt to confirm unsaved changes for new post with initial edits', async () => { await createNewPost( { title: 'My New Post', content: 'My content', excerpt: 'My excerpt', } ); - await assertIsDirty( false ); + await assertIsDirty( true ); } ); it( 'Should prompt if property changed without save', async () => { diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index c12fe93528c89..41706a18f4b9b 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -178,8 +178,7 @@ export function* setupEditor( post, edits, template ) { yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); if ( edits ) { - const record = { ...post, ...edits }; - yield dispatch( 'core', 'receiveEntityRecords', 'postType', post.type, record ); + yield editPost( edits ); } yield* __experimentalSubscribeSources(); } From aa81118ca5c69801a10478c9927fc7e505194fdc Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 21 Aug 2019 12:03:58 -0700 Subject: [PATCH 27/30] Editor: Add comment clarifying why we set content to a new function on edits. --- packages/editor/src/store/actions.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 41706a18f4b9b..78b58f919dc16 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -816,6 +816,9 @@ export function* resetEditorBlocks( blocks, options = {} ) { const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) }; if ( options.__unstableShouldCreateUndoLevel !== false ) { + // We create a new function here on every persistent edit + // to make sure the edit makes the post dirty and creates + // a new undo level. edits.content = ( { blocks: blocksForSerialization = [] } ) => serializeBlocks( blocksForSerialization ); } From 46306750af3fe1f90375299f0782fbf7e5417cdc Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 21 Aug 2019 12:13:44 -0700 Subject: [PATCH 28/30] Demo: Fix tests to consider the initial edits dirtying. --- packages/e2e-tests/specs/demo.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/specs/demo.test.js b/packages/e2e-tests/specs/demo.test.js index f5181a8a0e968..9cffda7ed37bb 100644 --- a/packages/e2e-tests/specs/demo.test.js +++ b/packages/e2e-tests/specs/demo.test.js @@ -28,12 +28,12 @@ describe( 'new editor state', () => { await visitAdminPage( 'post-new.php', 'gutenberg-demo' ); } ); - it( 'content should load without making the post dirty', async () => { + it( 'content should load, making the post dirty', async () => { const isDirty = await page.evaluate( () => { const { select } = window.wp.data; return select( 'core/editor' ).isEditedPostDirty(); } ); - expect( isDirty ).toBeFalsy(); + expect( isDirty ).toBeTruthy(); } ); it( 'should be immediately saveable', async () => { From a0202411f9aa545af0c60234ded99e583d825cc2 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Wed, 21 Aug 2019 13:55:16 -0700 Subject: [PATCH 29/30] Core Data: Set auto-drafts to drafts when autosaving them. --- packages/core-data/src/actions.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 91f284959d796..b19aa86775b07 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -256,13 +256,16 @@ export function* saveEntityRecord( // to the actual persisted entity if the edits don't // have a value. let data = { ...persistedRecord, ...autosavePost, ...record }; - data = Object.keys( data ).reduce( ( acc, key ) => { - if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { - // Edits should be the "raw" attribute values. - acc[ key ] = get( data[ key ], 'raw', data[ key ] ); - } - return acc; - }, {} ); + data = Object.keys( data ).reduce( + ( acc, key ) => { + if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { + // Edits should be the "raw" attribute values. + acc[ key ] = get( data[ key ], 'raw', data[ key ] ); + } + return acc; + }, + { status: data.status === 'auto-draft' ? 'draft' : data.status } + ); updatedRecord = yield apiFetch( { path: `${ path }/autosaves`, method: 'POST', @@ -275,7 +278,7 @@ export function* saveEntityRecord( yield receiveEntityRecords( kind, name, - { ...persistedRecord, ...updatedRecord }, + { ...persistedRecord, ...data, ...updatedRecord }, undefined, true ); From 272dfd27588b4574871cb748b01bdd425094a299 Mon Sep 17 00:00:00 2001 From: epiqueras Date: Thu, 22 Aug 2019 08:36:09 -0700 Subject: [PATCH 30/30] Core Data: Handle receiving autosaves correctly when editing non-autosave-persisting-properties. --- packages/core-data/src/actions.js | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index b19aa86775b07..dec975890ba06 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -237,6 +237,10 @@ export function* saveEntityRecord( const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; if ( isAutosave ) { + // Most of this autosave logic is very specific to posts. + // This is fine for now as it is the only supported autosave, + // but ideally this should all be handled in the back end, + // so the client just sends and receives objects. const persistedRecord = yield select( 'getRawEntityRecord', kind, @@ -275,13 +279,27 @@ export function* saveEntityRecord( // when its update is requested by the author and the post had // draft or auto-draft status. if ( persistedRecord.id === updatedRecord.id ) { - yield receiveEntityRecords( - kind, - name, - { ...persistedRecord, ...data, ...updatedRecord }, - undefined, - true - ); + let newRecord = { ...persistedRecord, ...data, ...updatedRecord }; + newRecord = Object.keys( newRecord ).reduce( ( acc, key ) => { + // These properties are persisted in autosaves. + if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { + // Edits should be the "raw" attribute values. + acc[ key ] = get( newRecord[ key ], 'raw', newRecord[ key ] ); + } else if ( key === 'status' ) { + // Status is only persisted in autosaves when going from + // "auto-draft" to "draft". + acc[ key ] = + persistedRecord.status === 'auto-draft' && + newRecord.status === 'draft' ? + newRecord.status : + persistedRecord.status; + } else { + // These properties are not persisted in autosaves. + acc[ key ] = get( persistedRecord[ key ], 'raw', persistedRecord[ key ] ); + } + return acc; + }, {} ); + yield receiveEntityRecords( kind, name, newRecord, undefined, true ); } else { yield receiveAutosaves( persistedRecord.id, updatedRecord ); }