diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index 884d2cc3910eb8..905e9cc077847d 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. @@ -309,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_ @@ -740,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_ @@ -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/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md index 90db77965f5995..658ba7dc930213 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/lib/blocks.php b/lib/blocks.php index cf66e1d4044a89..b79745e31b17c9 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -46,6 +46,26 @@ function gutenberg_reregister_core_block_types() { } add_action( 'init', 'gutenberg_reregister_core_block_types' ); +/** + * Adds new block categories needed by the Gutenberg plugin. + * + * @param array $categories List of block categories. + * + * @return array List of block categories with the new categories added. + */ +function gutenberg_filter_block_categories( $categories ) { + return array_merge( + $categories, + array( + array( + 'slug' => 'theme', + 'title' => __( 'Theme Blocks', 'gutenberg' ), + ), + ) + ); +} +add_filter( 'block_categories', 'gutenberg_filter_block_categories' ); + /** * Registers a new block style. * diff --git a/package-lock.json b/package-lock.json index 2febb418532ae1..5ee6b636dcf9a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4761,6 +4761,7 @@ "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/data": "file:packages/data", "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", "@wordpress/url": "file:packages/url", "equivalent-key-map": "^0.2.2", "lodash": "^4.17.14", diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 126e8c294fa1b7..d61ee52ae7fa09 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,12 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { } }, onReplace( blocks, indexToSelect ) { + if ( + blocks.length && + ! isUnmodifiedDefaultBlock( blocks[ blocks.length - 1 ] ) + ) { + __unstableMarkLastChangeAsPersistent(); + } replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect ); }, onShiftSelection() { diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 0d1feef14ff39d..c098d8fb5dafd3 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -44,7 +44,8 @@ class InnerBlocks extends Component { } componentDidMount() { - const { innerBlocks } = this.props.block; + const { block, value, replaceInnerBlocks } = this.props; + const { innerBlocks } = block; // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) { this.synchronizeBlocksWithTemplate(); @@ -55,10 +56,21 @@ class InnerBlocks extends Component { templateInProcess: false, } ); } + + // Set controlled blocks value from parent, if any. + if ( value ) { + replaceInnerBlocks( value ); + } } componentDidUpdate( prevProps ) { - const { template, block } = this.props; + const { + block, + template, + isLastBlockChangePersistent, + onInput, + onChange, + } = this.props; const { innerBlocks } = block; this.updateNestedSettings(); @@ -69,6 +81,14 @@ class InnerBlocks extends Component { this.synchronizeBlocksWithTemplate(); } } + + // Sync with controlled blocks value from parent, if possible. + if ( prevProps.block.innerBlocks !== innerBlocks ) { + const resetFunc = isLastBlockChangePersistent ? onInput : onChange; + if ( resetFunc ) { + resetFunc( innerBlocks ); + } + } } /** @@ -151,6 +171,7 @@ InnerBlocks = compose( [ getBlockListSettings, getBlockRootClientId, getTemplateLock, + isLastBlockChangePersistent, } = select( 'core/block-editor' ); const { clientId } = ownProps; const block = getBlock( clientId ); @@ -161,6 +182,7 @@ InnerBlocks = compose( [ blockListSettings: getBlockListSettings( clientId ), hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ), parentLock: getTemplateLock( rootClientId ), + isLastBlockChangePersistent: isLastBlockChangePersistent(), }; } ), withDispatch( ( dispatch, ownProps ) => { diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 67d460b4b4b404..fccce3433d132a 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -62,6 +62,11 @@ import * as tagCloud from './tag-cloud'; import * as classic from './classic'; +// Custom Entity Blocks +import * as post from './post'; +import * as postTitle from './post-title'; +import * as postContent from './post-content'; + /** * Function to register an individual block. * @@ -138,6 +143,11 @@ export const registerCoreBlocks = () => { textColumns, verse, video, + + // Register Custom Entity Blocks. + post, + postTitle, + postContent, ].forEach( registerBlock ); setDefaultBlockName( paragraph.name ); diff --git a/packages/block-library/src/post-content/block.json b/packages/block-library/src/post-content/block.json new file mode 100644 index 00000000000000..7472bd1b04c150 --- /dev/null +++ b/packages/block-library/src/post-content/block.json @@ -0,0 +1,4 @@ +{ + "name": "core/post-content", + "category": "theme" +} diff --git a/packages/block-library/src/post-content/edit.js b/packages/block-library/src/post-content/edit.js new file mode 100644 index 00000000000000..798744be0a8f82 --- /dev/null +++ b/packages/block-library/src/post-content/edit.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { useMemo, useCallback } from '@wordpress/element'; +import { parse } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; +import { serializeBlocks } from '@wordpress/editor'; + +export default function PostContentEdit() { + const [ content, setContent ] = useEntityProp( 'post', 'content' ); + const initialBlocks = useMemo( () => parse( content ), [] ); + const [ blocks = initialBlocks, setBlocks ] = useEntityProp( 'post', 'blocks' ); + return ( + { + setContent( ( { blocks: blocksForSerialization = [] } ) => + serializeBlocks( blocksForSerialization ) + ); + }, [] ) } + /> + ); +} diff --git a/packages/block-library/src/post-content/icon.js b/packages/block-library/src/post-content/icon.js new file mode 100644 index 00000000000000..2cd26e9d5f5a31 --- /dev/null +++ b/packages/block-library/src/post-content/icon.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + + + + +); diff --git a/packages/block-library/src/post-content/index.js b/packages/block-library/src/post-content/index.js new file mode 100644 index 00000000000000..76a1a07148094e --- /dev/null +++ b/packages/block-library/src/post-content/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import icon from './icon'; +import edit from './edit'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + title: __( 'Post Content' ), + icon, + edit, +}; diff --git a/packages/block-library/src/post-title/block.json b/packages/block-library/src/post-title/block.json new file mode 100644 index 00000000000000..11d61129406b8a --- /dev/null +++ b/packages/block-library/src/post-title/block.json @@ -0,0 +1,4 @@ +{ + "name": "core/post-title", + "category": "theme" +} diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js new file mode 100644 index 00000000000000..80b007a1774657 --- /dev/null +++ b/packages/block-library/src/post-title/edit.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { RichText } from '@wordpress/block-editor'; +import { cleanForSlug } from '@wordpress/editor'; +import { __ } from '@wordpress/i18n'; + +export default function PostTitleEdit() { + const [ title, setTitle ] = useEntityProp( 'post', 'title' ); + const [ , setSlug ] = useEntityProp( 'post', 'slug' ); + return ( + { + setTitle( value ); + setSlug( cleanForSlug( value ) ); + } } + tagName="h1" + placeholder={ __( 'Title' ) } + formattingControls={ [] } + /> + ); +} diff --git a/packages/block-library/src/post-title/icon.js b/packages/block-library/src/post-title/icon.js new file mode 100644 index 00000000000000..6dc60909619f82 --- /dev/null +++ b/packages/block-library/src/post-title/icon.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + + + + +); diff --git a/packages/block-library/src/post-title/index.js b/packages/block-library/src/post-title/index.js new file mode 100644 index 00000000000000..ba834fe69b0384 --- /dev/null +++ b/packages/block-library/src/post-title/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import icon from './icon'; +import edit from './edit'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + title: __( 'Post Title' ), + icon, + edit, +}; diff --git a/packages/block-library/src/post/block.json b/packages/block-library/src/post/block.json new file mode 100644 index 00000000000000..a8a2be4fe2eb11 --- /dev/null +++ b/packages/block-library/src/post/block.json @@ -0,0 +1,9 @@ +{ + "name": "core/post", + "category": "theme", + "attributes": { + "postId": { + "type": "number" + } + } +} diff --git a/packages/block-library/src/post/edit.js b/packages/block-library/src/post/edit.js new file mode 100644 index 00000000000000..038d0552b31824 --- /dev/null +++ b/packages/block-library/src/post/edit.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { useState, useCallback } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { + Placeholder, + TextControl, + Button, + Spinner, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { EntityProvider } from '@wordpress/core-data'; +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function PostEdit( { attributes: { postId }, setAttributes } ) { + const [ placeholderPostId, setPlaceholderPostId ] = useState(); + const onPostIdSubmit = useCallback( ( event ) => { + event.preventDefault(); + + const value = event.currentTarget[ 0 ].value; + if ( ! value ) { + return; + } + + setAttributes( { postId: Number( value ) } ); + }, [] ); + + const entity = useSelect( + ( select ) => + postId && select( 'core' ).getEntityRecord( 'postType', 'post', postId ), + [ postId ] + ); + + if ( ! postId ) { + return ( + +
+ + + +
+ ); + } + + return entity ? ( + + + + ) : ( + + + + ); +} diff --git a/packages/block-library/src/post/index.js b/packages/block-library/src/post/index.js new file mode 100644 index 00000000000000..3d7ab2f8393b93 --- /dev/null +++ b/packages/block-library/src/post/index.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + title: __( 'Post' ), + edit, + save, +}; diff --git a/packages/block-library/src/post/save.js b/packages/block-library/src/post/save.js new file mode 100644 index 00000000000000..039b6d8e9efb1f --- /dev/null +++ b/packages/block-library/src/post/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function PostSave() { + return ; +} diff --git a/packages/block-library/src/post/style.scss b/packages/block-library/src/post/style.scss new file mode 100644 index 00000000000000..f3c57fddbad860 --- /dev/null +++ b/packages/block-library/src/post/style.scss @@ -0,0 +1,11 @@ +.wp-block-post { + // Extra specificity to override default Placeholder styles. + &__placeholder-form.wp-block-post__placeholder-form { + align-items: center; + text-align: left; + } + + &__placeholder-input { + width: 100px; + } +} diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index fd78fcc3b18d11..9443f2798fdf1a 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -23,6 +23,7 @@ @import "./text-columns/style.scss"; @import "./verse/style.scss"; @import "./video/style.scss"; +@import "./post/style.scss"; // The following selectors have increased specificity (using the :root prefix) // to assure colors take effect over another base class color, mainly to let diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 405e60109adda3..64c44c3a37c8be 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -29,6 +29,7 @@ export const DEFAULT_CATEGORIES = [ { slug: 'widgets', title: __( 'Widgets' ) }, { slug: 'embed', title: __( 'Embeds' ) }, { slug: 'reusable', title: __( 'Reusable Blocks' ) }, + { slug: 'theme', title: __( 'Theme Blocks' ) }, ]; /** diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 64752e8ce030df..7d931ab20883eb 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/package.json b/packages/core-data/package.json index 6f55b40cd39f69..b2dae8d258be35 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -26,6 +26,7 @@ "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", + "@wordpress/element": "file:../element", "@wordpress/url": "file:../url", "equivalent-key-map": "^0.2.2", "lodash": "^4.17.14", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 80d9fa1915b19a..b19aa86775b074 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -132,7 +132,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, @@ -147,9 +147,9 @@ 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 ] ) : + merge( {}, recordValue, edits[ key ] ) : edits[ key ]; acc[ key ] = isEqual( recordValue, value ) ? undefined : value; return acc; @@ -235,9 +235,10 @@ export function* saveEntityRecord( let error; try { const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; + if ( isAutosave ) { const persistedRecord = yield select( - 'getEntityRecord', + 'getRawEntityRecord', kind, name, recordId @@ -255,18 +256,35 @@ 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 ( key in [ 'title', 'excerpt', 'content' ] ) { - 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', 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, ...data, ...updatedRecord }, + undefined, + true + ); + } else { + yield receiveAutosaves( persistedRecord.id, updatedRecord ); + } } else { updatedRecord = yield apiFetch( { path, diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js new file mode 100644 index 00000000000000..bbb14563a58085 --- /dev/null +++ b/packages/core-data/src/entity-provider.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext, useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { defaultEntities } from './entities'; + +const entities = { + ...defaultEntities.reduce( ( acc, entity ) => { + acc[ entity.name ] = { kind: entity.kind, context: createContext() }; + return acc; + }, {} ), + post: { kind: 'postType', context: createContext() }, +}; + +/** + * Context provider component for providing + * an entity for a specific entity type. + * + * @param {number} id The entity ID. + * @param {string} type The entity type. + * @param {*} children The children to wrap. + * + * @return {Object} The provided children, wrapped with + * the entity's context provider. + */ +export default function EntityProvider( { id, type, children } ) { + const Provider = entities[ type ].context.Provider; + return { children }; +} + +/** + * Hook that returns the value and a setter for the + * specified property of the nearest provided + * entity of the specified type. + * + * @param {string} type The entity type. + * @param {string} prop The property name. + * + * @return {[*, Function]} A tuple where the first item is the + * property value and the second is the + * setter. + */ +export function useEntityProp( type, prop ) { + const kind = entities[ type ].kind; + const id = useContext( entities[ type ].context ); + + const value = useSelect( + ( select ) => { + const entity = select( 'core' ).getEditedEntityRecord( kind, type, id ); + return entity && entity[ prop ]; + }, + [ kind, type, id, prop ] + ); + + const { editEntityRecord } = useDispatch( 'core' ); + const setValue = useCallback( + ( newValue ) => { + editEntityRecord( kind, type, id, { + [ prop ]: newValue, + } ); + }, + [ kind, type, id, prop ] + ); + + return [ value, setValue ]; +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index b0c5718ab9b888..2cdddb960e448b 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -48,3 +48,5 @@ registerStore( REDUCER_KEY, { selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); + +export { default as EntityProvider, useEntityProp } from './entity-provider'; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 7f0972e19e481b..48c3a6da889e84 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 ]; @@ -330,18 +333,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 ); - nextState.flattenedUndo = 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: { ...nextState.flattenedUndo, ...action.edits }, + edits: { ...action.edits, ...state.flattenedUndo }, } ); return nextState; diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 0f5cc1dd507809..317b758748720f 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -107,6 +107,34 @@ 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 = 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; + }, {} ) + ); + }, + ( state ) => [ state.entities.data ] +); + /** * Returns the Entity's records. * @@ -156,8 +184,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 ]; @@ -194,16 +221,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 ) => ( { + ...getRawEntityRecord( state, kind, name, recordId ), + ...getEntityRecordEdits( state, kind, name, recordId ), + } ), ( state ) => [ state.entities.data ] ); @@ -237,12 +258,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 30895ce5590dea..85f1b5dd921620 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 () => { @@ -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/e2e-tests/specs/demo.test.js b/packages/e2e-tests/specs/demo.test.js index f5181a8a0e9683..9cffda7ed37bb1 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 () => { diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index be98bc2ef8bc67..78b58f919dc16d 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,38 +1,30 @@ /** * External dependencies */ -import { castArray, pick, mapValues, has } from 'lodash'; -import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; +import { has, castArray } from 'lodash'; /** * WordPress dependencies */ import deprecated from '@wordpress/deprecated'; import { dispatch, select, apiFetch } from '@wordpress/data-controls'; -import { - parse, - synchronizeBlocksWithTemplate, -} from '@wordpress/blocks'; +import { parse, synchronizeBlocksWithTemplate } from '@wordpress/blocks'; 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 serializeBlocks from './utils/serialize-blocks'; import { awaitNextStateChange, getRegistry } from './controls'; import * as sources from './block-sources'; @@ -183,8 +175,11 @@ export function* setupEditor( post, edits, template ) { edits, template, }; - yield resetEditorBlocks( blocks ); + yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); + if ( edits ) { + yield editPost( edits ); + } yield* __experimentalSubscribeSources(); } @@ -210,7 +205,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; } @@ -242,7 +237,7 @@ export function* __experimentalSubscribeSources() { } if ( reset ) { - yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) ); + yield resetEditorBlocks( yield select( STORE_KEY, 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } ); } } } @@ -286,7 +281,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 * @@ -295,73 +290,20 @@ 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. + * Action for dispatching that a post update request has finished. * - * @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} options * - * @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, -} ) { +export function __experimentalRequestPostUpdateFinish( options = {} ) { return { - type: 'REQUEST_POST_UPDATE_FAILURE', - optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, - post, - edits, - error, + type: 'REQUEST_POST_UPDATE_FINISH', options, }; } @@ -402,13 +344,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( STORE_KEY, 'getCurrentPost' ); + yield dispatch( 'core', 'editEntityRecord', 'postType', type, id, edits ); } /** @@ -432,173 +372,61 @@ export function __experimentalOptimisticUpdatePost( edits ) { * @param {Object} options */ export function* savePost( options = {} ) { - const isEditedPostSaveable = yield select( - STORE_KEY, - 'isEditedPostSaveable' - ); - if ( ! isEditedPostSaveable ) { + if ( ! ( yield select( STORE_KEY, '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' - ); + yield dispatch( STORE_KEY, 'editPost', { + content: yield select( STORE_KEY, 'getEditedPostContent' ), + } ); - let toSend = { - ...edits, - content: editedPostContent, - id: post.id, + yield __experimentalRequestPostUpdateStart( options ); + const previousRecord = yield select( STORE_KEY, 'getCurrentPost' ); + const edits = { + id: previousRecord.id, + ...( yield select( + 'core', + 'getEntityRecordNonTransientEdits', + 'postType', + previousRecord.type, + previousRecord.id + ) ), }; - - const currentPostType = yield select( - STORE_KEY, - 'getCurrentPostType' - ); - - const postType = yield select( - 'core', - 'getPostType', - currentPostType - ); - yield dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateStart', - options, + 'core', + 'saveEntityRecord', + 'postType', + previousRecord.type, + edits, + options ); + yield __experimentalRequestPostUpdateFinish( options ); - // Optimistically apply updates under the assumption that the post - // will be updated. See below logic in success resolution for revert - // if the autosave is applied as a revision. - yield dispatch( - STORE_KEY, - '__experimentalOptimisticUpdatePost', - toSend + const error = yield select( + 'core', + 'getLastEntitySaveError', + 'postType', + previousRecord.type, + previousRecord.id ); - - 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 ( error ) { + const args = getNotificationArgumentsForSaveFail( { + post: previousRecord, + edits, + error, } ); - - if ( isAutosave ) { - yield dispatch( 'core', 'receiveAutosaves', post.id, newPost ); - } else { - yield dispatch( STORE_KEY, 'resetPost', newPost ); + if ( args.length ) { + yield dispatch( 'core/notices', 'createErrorNotice', ...args ); } - - 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, + } else { + const updatedRecord = yield select( STORE_KEY, 'getCurrentPost' ); + const args = getNotificationArgumentsForSaveSuccess( { + previousPost: previousRecord, + post: updatedRecord, + postType: yield select( 'core', 'getPostType', updatedRecord.type ), 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 - ); + if ( args.length ) { + yield dispatch( 'core/notices', 'createSuccessNotice', ...args ); } } } @@ -698,19 +526,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' ); } /** @@ -878,7 +706,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. @@ -946,7 +774,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 +813,17 @@ 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 ) { + // 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 ); + } + + yield* editPost( edits ); } /* diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index 07c92803bd0a13..f158a4664dec7c 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 ef6ad6fd798c07..a88e05f30ac775 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -2,32 +2,17 @@ * 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 */ import { combineReducers } from '@wordpress/data'; -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 +99,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; @@ -336,26 +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 { - 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, + pending: action.type === 'REQUEST_POST_UPDATE_START', options: action.options || {}, }; } @@ -514,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 @@ -587,15 +383,13 @@ export function editorSettings( state = EDITOR_SETTINGS_DEFAULTS, action ) { } export default optimist( combineReducers( { - editor, - initialEdits, - currentPost, + postId, + postType, preferences, saving, postLock, reusableBlocks, template, - previewLink, postSavingLock, isReady, editorSettings, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index fa6843010ab704..daf7adbd9ee2ae 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'; @@ -39,6 +37,7 @@ import { AUTOSAVE_PROPERTIES, } from './constants'; import { getPostRawValue } from './reducer'; +import serializeBlocks from './utils/serialize-blocks'; /** * Shared reference to an empty object for cases where it is important to avoid @@ -49,6 +48,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 +64,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 +76,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 +100,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 +122,17 @@ 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 ); -} + return false; +} ); /** * Returns true if there are no unsaved values for the current edit session and @@ -153,9 +155,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' ).getRawEntityRecord( '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 +178,7 @@ export function getCurrentPost( state ) { * @return {string} Post type. */ export function getCurrentPostType( state ) { - return state.currentPost.type; + return state.postType; } /** @@ -177,7 +190,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 +224,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 +262,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 +289,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 @@ -336,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; } @@ -392,15 +398,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 ) ) ) + ); } /** @@ -478,9 +488,9 @@ 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 ) ) ) { + 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. @@ -654,9 +664,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 +678,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 +694,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 +710,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 !! get( state.saving, [ 'options', 'isAutosave' ] ); } /** @@ -701,7 +724,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; } /** @@ -712,8 +738,19 @@ export function isPreviewingPost( state ) { * @return {string?} Preview Link. */ export function getEditedPostPreviewLink( state ) { + if ( state.saving.pending || isSavingPost( state ) ) { + return; + } + + let previewLink = getAutosaveAttribute( 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 } ); } @@ -731,7 +768,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 +811,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 @@ -801,43 +846,31 @@ 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. * * @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 ); +export const getEditedPostContent = createRegistrySelector( ( select ) => ( state ) => { + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + const record = select( 'core' ).getEditedEntityRecord( + 'postType', + postType, + postId + ); + if ( record ) { + 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 content; - }, - ( state ) => [ - state.editor.present.blocks.value, - state.editor.present.edits.content, - state.initialEdits.content, - ], -); + } + return ''; +} ); /** * Returns the reusable block with the given ID. @@ -956,7 +989,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 ) + ); } /** @@ -1130,7 +1166,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/store/test/actions.js b/packages/editor/src/store/test/actions.js index ecb588c0912235..cdc354dca9346c 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,167 @@ 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 checking if the post is saveable', () => true, () => { reset( isAutosave ); const { value } = fulfillment.next(); - expect( value ).toEqual( - select( STORE_KEY, 'isEditedPostSaveable' ) - ); + expect( value ).toEqual( select( STORE_KEY, 'isEditedPostSaveable' ) ); }, ], [ - 'yields action for selecting the post edits done', + 'yields an action for selecting the current edited post content', () => 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' ) - ); + expect( value ).toEqual( select( STORE_KEY, 'getEditedPostContent' ) ); }, ], [ - 'yields action for selecting the current post', + "yields an action for editing the post entity's content", () => true, () => { - const { value } = fulfillment.next( isEditedPostNew ); - expect( value ).toEqual( - select( STORE_KEY, 'getCurrentPost' ) - ); + const edits = { content: currentPost().content }; + const { value } = fulfillment.next( edits.content ); + expect( value ).toEqual( dispatch( STORE_KEY, 'editPost', edits ) ); }, ], [ - 'yields action for selecting the edited post content', + 'yields an action for signalling that an update to the post started', () => true, () => { - const { value } = fulfillment.next( currentPost() ); - expect( value ).toEqual( - select( STORE_KEY, 'getEditedPostContent' ) - ); - }, - ], - [ - 'yields action for selecting current post type slug', - () => true, - () => { - const { value } = fulfillment.next( editedPostContent ); - expect( value ).toEqual( - select( STORE_KEY, 'getCurrentPostType' ) - ); + const { value } = fulfillment.next(); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_START', + options: { isAutosave }, + } ); }, ], [ - 'yields action for selecting the post type object', + 'yields an action for selecting the current post', () => true, () => { - const { value } = fulfillment.next( postTypeSlug ); - expect( value ).toEqual( - select( 'core', 'getPostType', postTypeSlug ) - ); + const { value } = fulfillment.next(); + expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) ); }, ], [ - 'yields action for dispatching request post update start', + "yields an action for selecting the post entity's non transient edits", () => true, () => { - const { value } = fulfillment.next( postType ); + const post = currentPost(); + const { value } = fulfillment.next( post ); expect( value ).toEqual( - dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateStart', - { isAutosave } + select( + 'core', + 'getEntityRecordNonTransientEdits', + 'postType', + post.type, + post.id ) ); }, ], [ - 'yields action for dispatching optimistic update of post', + 'yields an action for dispatching an update to the post entity', () => true, () => { - const { value } = fulfillment.next(); + const post = currentPost(); + const { value } = fulfillment.next( post ); 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, + 'core', + 'saveEntityRecord', + 'postType', + post.type, + post, + { + isAutosave, + } ) ); }, ], [ - 'yields action for dispatching the removal of autosave notice', - ( isAutosaving ) => ! isAutosaving, + 'yields an action for signalling that an update to the post finished', + () => true, () => { const { value } = fulfillment.next(); - expect( value ).toEqual( - dispatch( - 'core/notices', - 'removeNotice', - 'autosave-exists' - ) - ); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_FINISH', + options: { isAutosave }, + } ); }, ], [ - 'yields action for selecting the currentUser', - ( isAutosaving ) => isAutosaving, + "yields an action for selecting the entity's save error", + () => true, () => { + const post = currentPost(); 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 + 'getLastEntitySaveError', + 'postType', + post.type, + post.id ) ); }, ], - ]; - 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', + 'yields an action for selecting the current post', + () => true, () => { - const { value } = fulfillment.next( [ 'foo', 'bar' ] ); - expect( value ).toEqual( - dispatch( - 'core/notices', - 'createErrorNotice', - ...[ 'Updating failed.', { id: 'SAVE_POST_NOTICE_ID' } ] - ) - ); + const { value } = fulfillment.next(); + expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) ); }, ], - ]; - const fetchSuccessConditions = [ [ - 'yields action for updating the post via the api', + 'yields an action for selecting the current post type config', + () => true, () => { - 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, - } - ) - ); + const post = currentPost(); + const { value } = fulfillment.next( post ); + expect( value ).toEqual( select( 'core', 'getPostType', post.type ) ); }, ], [ - 'yields action for dispatch the appropriate reset action', + 'yields an action for dispatching a success notice', + () => true, () => { - const { value } = fulfillment.next( savedPost() ); - - if ( isAutosave ) { - expect( value ).toEqual( dispatch( 'core', 'receiveAutosaves', postId, savedPost() ) ); - } else { - expect( value ).toEqual( dispatch( STORE_KEY, 'resetPost', savedPost() ) ); + 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', + } + ) + ); } }, ], [ - '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', + 'implicitly returns undefined', + () => true, () => { - 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 +246,27 @@ 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', () => { 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,14 +432,15 @@ 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(); expect( value ).toEqual( - actions.setupEditorState( - { content: { raw: '' }, status: 'publish' } - ) + actions.setupEditorState( { + content: { raw: '' }, + status: 'publish', + } ) ); } ); } ); @@ -691,51 +461,11 @@ 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 }, - } ); - } ); - } ); - describe( 'updatePost', () => { it( 'should return the UPDATE_POST action', () => { const edits = {}; @@ -748,11 +478,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 +517,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, } ); } ); } ); diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index d964c72c0f77a3..3fc6461b34e538 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -11,16 +11,11 @@ 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 +151,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 +183,11 @@ 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: {}, + pending: true, + options: { isAutosave: true }, } ); } ); } ); @@ -846,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/' ); - } ); - } ); } ); diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 6d10b7f863aead..73e0938ffccce4 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -20,10 +20,103 @@ import { RawHTML } from '@wordpress/element'; /** * Internal dependencies */ -import * as selectors from '../selectors'; +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 = () => ( { + getRawEntityRecord() { + 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() { + return { + ...this.getRawEntityRecord(), + ...this.getEntityRecordEdits(), + }; + }, + + 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 +137,7 @@ const { isCurrentPostScheduled, isEditedPostPublishable, isEditedPostSaveable, - isEditedPostAutosaveable: _isEditedPostAutosaveableRegistrySelector, + isEditedPostAutosaveable, isEditedPostEmpty, isEditedPostBeingScheduled, isEditedPostDateFloating, @@ -71,14 +164,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 +442,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 +528,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 +536,7 @@ describe( 'selectors', () => { it( 'should return the current post ID', () => { const state = { - currentPost: { id: 1 }, + postId: 1, }; expect( getCurrentPostId( state ) ).toBe( 1 ); @@ -673,9 +730,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 +1327,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 +1343,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 +1374,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 +1403,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 +1430,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 +1440,12 @@ describe( 'selectors', () => { excerpt: 'foo', }; }, - } ) ); + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + it( 'should return true if content has changes', () => { const state = { editor: { present: { @@ -1431,6 +1461,16 @@ describe( 'selectors', () => { excerpt: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + title: 'foo', + excerpt: 'foo', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); @@ -1439,19 +1479,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 +1495,16 @@ describe( 'selectors', () => { content: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + [ constantField ]: 'foo', + [ variantField ]: 'bar', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); @@ -1546,7 +1583,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: { @@ -1571,7 +1608,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', () => { @@ -1593,7 +1630,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: { @@ -1609,7 +1646,7 @@ describe( 'selectors', () => { currentPost: {}, }; - expect( isEditedPostEmpty( state ) ).toBe( false ); + expect( isEditedPostEmpty( state ) ).toBe( true ); } ); it( 'should return true if empty classic block', () => { @@ -2102,7 +2139,7 @@ describe( 'selectors', () => { } ); } ); - it( 'defers to returning an edited post attribute', () => { + it( 'serializes blocks, if any', () => { const block = createBlock( 'core/block' ); const state = { @@ -2122,7 +2159,7 @@ describe( 'selectors', () => { const content = getEditedPostContent( state ); - expect( content ).toBe( 'custom edit' ); + expect( content ).toBe( '' ); } ); it( 'returns serialization of blocks', () => { 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 00000000000000..7301350399ca50 --- /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; diff --git a/packages/editor/src/utils/index.js b/packages/editor/src/utils/index.js index 0f246c16e4aec8..d1105bd71f6dd9 100644 --- a/packages/editor/src/utils/index.js +++ b/packages/editor/src/utils/index.js @@ -5,3 +5,5 @@ import mediaUpload from './media-upload'; export { mediaUpload }; export { cleanForSlug } from './url.js'; + +export { default as serializeBlocks } from '../store/utils/serialize-blocks'; 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 75f6061483f832..00000000000000 --- 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 e86db9a9905d4b..00000000000000 --- 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 06abccb50fc5b6..00000000000000 --- 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 3c90ed2d895be8..00000000000000 --- 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 665851e632bd3f..00000000000000 --- 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 e383988864f77f..00000000000000 --- 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, - } ); - } ); -} );