diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md index b38e4ede0bf67..679b048a8fb2a 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. @@ -215,6 +217,32 @@ _Related_ - getClientIdsWithDescendants in core/block-editor store. +# **getCurrentEntityAttribute** + +Returns an attribute value of the saved entity. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _attributeName_ `string`: Entity attribute name. + +_Returns_ + +- `*`: Entity attribute value. + +# **getCurrentEntityKind** + +Returns the kind of the entity currently being edited, or null if the entity has +not yet been saved. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `?string`: The entity kind. + # **getCurrentPost** Returns the post currently being edited in its last known saved state, not @@ -244,7 +272,7 @@ _Returns_ # **getCurrentPostId** -Returns the ID of the post currently being edited, or null if the post has +Returns the ID of the post currently being edited, or undefined if the post has not yet been saved. _Parameters_ @@ -292,6 +320,21 @@ _Returns_ - `string`: Post type. +# **getEditedEntityAttribute** + +Returns a single attribute of the entity being edited, preferring the unsaved +edit if one exists, but falling back to the attribute for the last known +saved state of the entity. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _attributeName_ `string`: Entity attribute name. + +_Returns_ + +- `*`: Entity attribute value. + # **getEditedPostAttribute** Returns a single attribute of the post being edited, preferring the unsaved @@ -309,8 +352,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_ @@ -382,6 +424,22 @@ _Related_ - getGlobalBlockCount in core/block-editor store. +# **getHandlesFilteredEdits** + +Returns an object with the edits broken down into a +handled edits object, an edits object that should +be delegated to a parent editor, and the parent's +dispatching function, if any. + +_Parameters_ + +- _state_ `Object`: Editor state. +- _\_edits_ `Object`: The edits, defaults to all of the entity's current edits. + +_Returns_ + +- `Object`: The object with the grouped edits and the parent's dispatching function. + # **getInserterItems** _Related_ @@ -740,6 +798,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_ @@ -1032,18 +1091,23 @@ _Returns_ - `Object`: Action object -# **editPost** +# **editEntity** -Returns an action object used in signalling that attributes of the post have +Yields an action object used in signalling that attributes of the entity have been edited. _Parameters_ -- _edits_ `Object`: Post attributes to edit. +- _edits_ `Object`: Entity attributes to edit. -_Returns_ +# **editPost** -- `Object`: Action object. +Yields an action object used in signalling that attributes of the post have +been edited. + +_Parameters_ + +- _edits_ `Object`: Post attributes to edit. # **enablePublishSidebar** @@ -1143,10 +1207,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. @@ -1205,10 +1265,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 @@ -1236,6 +1292,18 @@ _Related_ - selectBlock in core/block-editor store. +# **serializeBlocks** + +Serializes blocks following backwards compatibility conventions. + +_Parameters_ + +- _blocksForSerialization_ `Array`: The blocks to serialize. + +_Returns_ + +- `string`: The blocks serialization. + # **setTemplateValidity** _Related_ @@ -1322,10 +1390,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/lib/blocks.php b/lib/blocks.php index cf66e1d4044a8..526268afe9929 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -28,6 +28,7 @@ function gutenberg_reregister_core_block_types() { 'shortcode.php' => 'core/shortcode', 'search.php' => 'core/search', 'tag-cloud.php' => 'core/tag-cloud', + 'site-title.php' => 'core/site-title', ); $registry = WP_Block_Type_Registry::get_instance(); @@ -46,6 +47,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' ), + ), + ) + ); +} +add_filter( 'block_categories', 'gutenberg_filter_block_categories' ); + /** * Registers a new block style. * diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 1d230d45fc3a2..7399ca2221ca9 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -210,6 +210,12 @@ _Returns_ Undocumented declaration. +# **EntityProvider** + +Context provider component for providing +the implementations of the entity getter +and setters. + # **FontSizePicker** Undocumented declaration. @@ -438,6 +444,62 @@ _Related_ - +# **useCurrentEntityAttribute** + +Hook for getting the currently persisted value +for an entity attribute. + +If there is no provider or it does not provide +an implementation for the required hook, +undefined is returned. + +_Parameters_ + +- _args_ `...*`: Any arguments the implementation might require. + +_Returns_ + +- `*`: The value. + +# **useEditedEntityAttribute** + +Hook for getting the edited value for an entity attribute, +falling back to the persisted value if there isn't +one. + +If there is no provider or it does not provide +an implementation for the required hook, +undefined is returned. + +_Parameters_ + +- _args_ `...*`: Any arguments the implementation might require. + +_Returns_ + +- `*`: The value. + +# **useEditEntity** + +Hook for accessing the entity's main edit function. + +If there is no provider or it does not provide +an implementation for the required hook, +a "noop" function is returned. + +_Returns_ + +- `Function`: The function or "noop". + +# **useEntity** + +Hook for accessing the entity getters and setters +provided by the nearest parent entity provider. + +_Returns_ + +- `Object`: The object with getters and setters. + # **Warning** Undocumented declaration. diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index d95ce1f3a9381..2c3e8a5b8f5a8 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -702,6 +702,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => { replaceBlocks, toggleSelection, setNavigationMode, + __unstableMarkLastChangeAsPersistent, } = dispatch( 'core/block-editor' ); return { @@ -755,6 +756,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/entity-provider/index.js b/packages/block-editor/src/components/entity-provider/index.js new file mode 100644 index 0000000000000..e34f9aa76fb3a --- /dev/null +++ b/packages/block-editor/src/components/entity-provider/index.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +const Context = createContext( {} ); + +/** + * Context provider component for providing + * the implementations of the entity getter + * and setters. + * + * @typedef {Function} EntityProvider + */ +export default Context.Provider; + +/** + * Hook for accessing the entity getters and setters + * provided by the nearest parent entity provider. + * + * @return {Object} The object with getters and setters. + */ +export function useEntity() { + return useContext( Context ); +} + +/** + * Hook for getting the currently persisted value + * for an entity attribute. + * + * If there is no provider or it does not provide + * an implementation for the required hook, + * undefined is returned. + * + * @param {...*} args Any arguments the implementation + * might require. + * + * @return {*} The value. + */ +export function useCurrentEntityAttribute( ...args ) { + const { useCurrentEntityAttribute: func } = useEntity(); + return func ? func( ...args ) : undefined; +} + +/** + * Hook for getting the edited value for an entity attribute, + * falling back to the persisted value if there isn't + * one. + * + * If there is no provider or it does not provide + * an implementation for the required hook, + * undefined is returned. + * + * @param {...*} args Any arguments the implementation + * might require. + * + * @return {*} The value. + */ +export function useEditedEntityAttribute( ...args ) { + const { useEditedEntityAttribute: func } = useEntity(); + return func ? func( ...args ) : undefined; +} + +const editEntityNoop = () => {}; + +/** + * Hook for accessing the entity's main edit function. + * + * If there is no provider or it does not provide + * an implementation for the required hook, + * a "noop" function is returned. + * + * @return {Function} The function or "noop". + */ +export function useEditEntity() { + const { useEditEntity: func } = useEntity(); + return func ? func() : editEntityNoop; +} diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 9ad37a8929b43..d621b6b214177 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -66,4 +66,6 @@ export { default as WritingFlow } from './writing-flow'; * State Related Components */ +export * from './entity-provider'; +export { default as EntityProvider } from './entity-provider'; export { default as BlockEditorProvider } from './provider'; diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 0d1feef14ff39..5ad31113d0569 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, blocks, 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,23 @@ class InnerBlocks extends Component { templateInProcess: false, } ); } + + // Set controlled blocks value from parent, if any. + if ( blocks ) { + replaceInnerBlocks( blocks ); + } } componentDidUpdate( prevProps ) { - const { template, block } = this.props; + const { + block, + template, + isLastBlockChangePersistent, + resetEditorBlocks, + resetEditorBlocksWithoutUndoLevel, + blocks, + replaceInnerBlocks, + } = this.props; const { innerBlocks } = block; this.updateNestedSettings(); @@ -69,6 +83,31 @@ class InnerBlocks extends Component { this.synchronizeBlocksWithTemplate(); } } + + // Sync with controlled blocks value from parent, if possible. + if ( this.isSyncingIncomingBlocks === innerBlocks ) { + this.isSyncingIncomingBlocks = null; + } else if ( prevProps.block.innerBlocks !== innerBlocks ) { + this.isSyncingIncomingBlocks = null; + + const resetFunc = isLastBlockChangePersistent ? + resetEditorBlocks : + resetEditorBlocksWithoutUndoLevel; + if ( resetFunc ) { + this.isSyncingOutcomingBlocks = innerBlocks; + resetFunc( innerBlocks ); + } + } + + // Accept changes to controlled blocks value from parent after a sync, if any. + if ( this.isSyncingOutcomingBlocks === blocks ) { + this.isSyncingOutcomingBlocks = null; + } else if ( prevProps.blocks !== blocks ) { + this.isSyncingOutcomingBlocks = null; + + this.isSyncingIncomingBlocks = blocks; + replaceInnerBlocks( blocks ); + } } /** @@ -151,6 +190,7 @@ InnerBlocks = compose( [ getBlockListSettings, getBlockRootClientId, getTemplateLock, + isLastBlockChangePersistent, } = select( 'core/block-editor' ); const { clientId } = ownProps; const block = getBlock( clientId ); @@ -161,6 +201,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 0d313bc171b20..7097afbb804e3 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -62,6 +62,12 @@ import * as tagCloud from './tag-cloud'; import * as classic from './classic'; +// Custom Entity Blocks +import * as siteTitle from './site-title'; +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 +144,12 @@ export const registerCoreBlocks = () => { textColumns, verse, video, + + // Register Custom Entity Blocks. + siteTitle, + 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 0000000000000..7472bd1b04c15 --- /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 0000000000000..a554f71185a4d --- /dev/null +++ b/packages/block-library/src/post-content/edit.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { EntityHandlers } from '@wordpress/editor'; + +export default function PostContentEdit() { + return ( + + ); +} 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 0000000000000..2cd26e9d5f5a3 --- /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 0000000000000..76a1a07148094 --- /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 0000000000000..11d61129406b8 --- /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 0000000000000..baa1d87932ce9 --- /dev/null +++ b/packages/block-library/src/post-title/edit.js @@ -0,0 +1,28 @@ +/** + * WordPress dependencies + */ +import { + useEditEntity, + RichText, + useEditedEntityAttribute, +} from '@wordpress/block-editor'; +import { cleanForSlug } from '@wordpress/editor'; +import { __ } from '@wordpress/i18n'; + +export default function PostTitleEdit() { + const editEntity = useEditEntity(); + return ( + + editEntity( { + title: value, + slug: 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 0000000000000..6dc60909619f8 --- /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 0000000000000..ba834fe69b038 --- /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 0000000000000..a8a2be4fe2eb1 --- /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 0000000000000..b291db4ed9aca --- /dev/null +++ b/packages/block-library/src/post/edit.js @@ -0,0 +1,71 @@ +/** + * 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 { EntityHandlers } from '@wordpress/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 0000000000000..3d7ab2f8393b9 --- /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 0000000000000..6e3eee15cb6e6 --- /dev/null +++ b/packages/block-library/src/post/save.js @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { EntityHandlers } from '@wordpress/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 0000000000000..f3c57fddbad86 --- /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/site-title/block.json b/packages/block-library/src/site-title/block.json new file mode 100644 index 0000000000000..ab9a59291621c --- /dev/null +++ b/packages/block-library/src/site-title/block.json @@ -0,0 +1,4 @@ +{ + "name": "core/site-title", + "category": "theme" +} diff --git a/packages/block-library/src/site-title/edit.js b/packages/block-library/src/site-title/edit.js new file mode 100644 index 0000000000000..572e59a80b23d --- /dev/null +++ b/packages/block-library/src/site-title/edit.js @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { + useEditEntity, + RichText, + useEditedEntityAttribute, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { EntityHandlers } from '@wordpress/editor'; +import { Placeholder, Spinner } from '@wordpress/components'; + +function TitleInput() { + const editEntity = useEditEntity(); + return ( + + editEntity( { + title, + } ) + } + tagName="h1" + placeholder={ __( 'Site Title' ) } + formattingControls={ [] } + /> + ); +} + +export default function SiteTitleEdit() { + const entity = useSelect( + ( select ) => select( 'core' ).getEntityRecord( 'root', 'site' ), + [] + ); + return entity ? ( + + + + ) : ( + + + + ); +} diff --git a/packages/block-library/src/site-title/icon.js b/packages/block-library/src/site-title/icon.js new file mode 100644 index 0000000000000..1ab74dec2c32d --- /dev/null +++ b/packages/block-library/src/site-title/icon.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path, Circle } from '@wordpress/components'; + +export default ( + + + + + +); diff --git a/packages/block-library/src/site-title/index.js b/packages/block-library/src/site-title/index.js new file mode 100644 index 0000000000000..4b8fc50b86b34 --- /dev/null +++ b/packages/block-library/src/site-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: __( 'Site Title' ), + icon, + edit, +}; diff --git a/packages/block-library/src/site-title/index.php b/packages/block-library/src/site-title/index.php new file mode 100644 index 0000000000000..6e5b5786436f0 --- /dev/null +++ b/packages/block-library/src/site-title/index.php @@ -0,0 +1,28 @@ +%s', get_bloginfo( 'name' ) ); +} + +/** + * Registers the `core/site-title` block on the server. + */ +function register_block_core_site_title() { + register_block_type( + 'core/site-title', + array( + 'render_callback' => 'render_block_core_site_title', + ) + ); +} +add_action( 'init', 'register_block_core_site_title' ); diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index fd78fcc3b18d1..9443f2798fdf1 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/api/test/parser.js b/packages/blocks/src/api/test/parser.js index f10001cb0c013..e25e6a382339f 100644 --- a/packages/blocks/src/api/test/parser.js +++ b/packages/blocks/src/api/test/parser.js @@ -355,20 +355,6 @@ describe( 'block parser', () => { ); expect( value ).toBe( 'chicken' ); } ); - - it( 'should return undefined for meta attributes', () => { - const value = getBlockAttribute( - 'content', - { - type: 'string', - source: 'meta', - meta: 'content', - }, - '
chicken
', - {} - ); - expect( value ).toBeUndefined(); - } ); } ); describe( 'getBlockAttributes()', () => { diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 405e60109adda..64c44c3a37c8b 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/src/actions.js b/packages/core-data/src/actions.js index 43861be226ca5..2fc6564c3762f 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -3,6 +3,11 @@ */ import { castArray, get, merge, isEqual, find } from 'lodash'; +/** + * WordPress dependencies + */ +import { dispatch } from '@wordpress/data-controls'; + /** * Internal dependencies */ @@ -69,6 +74,15 @@ export function addEntities( entities ) { * @return {Object} Action object. */ export function receiveEntityRecords( kind, name, records, query, invalidateCache = false ) { + // Ideally, the API should do this, but it doesn't + // for some entities, so we make sure these properties + // are set correctly here. + records = castArray( records ).map( ( record ) => { + record.kind = kind; + record.type = name; + return record; + } ); + let action; if ( query ) { action = receiveQueriedItems( records, query ); @@ -147,9 +161,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; @@ -220,7 +234,11 @@ export function* saveEntityRecord( kind, name, record, - { isAutosave = false } = { isAutosave: false } + { + isAutosave = false, + getSuccessNoticeActionArgs, + getFailureNoticeActionArgs, + } = { isAutosave: false } ) { const entities = yield getKindEntities( kind ); const entity = find( entities, { kind, name } ); @@ -231,16 +249,16 @@ export function* saveEntityRecord( const recordId = record[ entityIdKey ]; yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId, isAutosave }; + let persistedRecord; + if ( isAutosave || getSuccessNoticeActionArgs || getFailureNoticeActionArgs ) { + persistedRecord = yield select( 'getEntityRecord', kind, name, recordId ); + } let error; try { const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`; + let updatedRecord; + if ( isAutosave ) { - const persistedRecord = yield select( - 'getEntityRecord', - kind, - name, - recordId - ); const currentUser = yield select( 'getCurrentUser' ); const currentUserId = currentUser ? currentUser.id : undefined; const autosavePost = yield select( @@ -255,27 +273,61 @@ export function* saveEntityRecord( // have a value. let data = { ...persistedRecord, ...autosavePost, ...record }; data = Object.keys( data ).reduce( ( acc, key ) => { - if ( key in [ 'title', 'excerpt', 'content' ] ) { + if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) { acc[ key ] = get( data[ key ], 'raw', data[ key ] ); } return acc; }, {} ); - const autosave = yield apiFetch( { + updatedRecord = yield apiFetch( { path: `${ path }/autosaves`, method: 'POST', data, } ); - yield receiveAutosaves( persistedRecord.id, autosave ); + // An autosave may be processed by the server as a regular save + // when its update is requested by the author and the post had + // draft or auto-draft status. + if ( persistedRecord.id === updatedRecord.id ) { + yield receiveEntityRecords( + kind, + name, + { ...persistedRecord, ...updatedRecord }, + undefined, + true + ); + } else { + yield receiveAutosaves( persistedRecord.id, updatedRecord ); + } } else { - const updatedRecord = yield apiFetch( { + updatedRecord = yield apiFetch( { path, method: recordId ? 'PUT' : 'POST', data: record, } ); yield receiveEntityRecords( kind, name, updatedRecord, undefined, true ); } + + if ( getSuccessNoticeActionArgs ) { + const postType = updatedRecord.type || persistedRecord.type; + const args = + postType && + getSuccessNoticeActionArgs( + persistedRecord, + updatedRecord, + yield select( 'getPostType', postType ) + ); + if ( args && args.length ) { + yield dispatch( ...args ); + } + } } catch ( _error ) { error = _error; + + if ( getFailureNoticeActionArgs ) { + const args = getFailureNoticeActionArgs( persistedRecord, record, error ); + if ( args && args.length ) { + yield dispatch( ...args ); + } + } } yield { type: 'SAVE_ENTITY_RECORD_FINISH', diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index a78142b3360f9..0541e240a7f6c 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -12,6 +12,7 @@ import { apiFetch, select } from './controls'; export const DEFAULT_ENTITY_KEY = 'id'; export const defaultEntities = [ + { name: 'site', kind: 'root', baseURL: '/wp/v2/settings' }, { name: 'postType', kind: 'root', key: 'slug', baseURL: '/wp/v2/types' }, { name: 'media', kind: 'root', baseURL: '/wp/v2/media', plural: 'mediaItems' }, { name: 'taxonomy', kind: 'root', key: 'slug', baseURL: '/wp/v2/taxonomies', plural: 'taxonomies' }, diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index b0c5718ab9b88..88d31a557e4be 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { registerStore } from '@wordpress/data'; +import { controls as dataControls } from '@wordpress/data-controls'; /** * Internal dependencies @@ -43,7 +44,7 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => { registerStore( REDUCER_KEY, { reducer, - controls, + controls: { ...dataControls, ...controls }, actions: { ...actions, ...entityActions }, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 7f0972e19e481..7bcf06afe1b85 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -330,18 +330,19 @@ export function undo( state = UNDO_INITIAL_STATE, action ) { edits: { ...state.flattenedUndo, ...action.meta.undo.edits }, }, ]; - } else { - // Clear potential redos, because this only supports linear history. - nextState = state.slice( 0, state.offset || undefined ); - 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/resolvers.js b/packages/core-data/src/resolvers.js index 8edfdbf895cde..670cb4adf2a75 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -47,7 +47,7 @@ export function* getCurrentUser() { * @param {string} name Entity name. * @param {number} key Record's key */ -export function* getEntityRecord( kind, name, key ) { +export function* getEntityRecord( kind, name, key = '' ) { const entities = yield getKindEntities( kind ); const entity = find( entities, { kind, name } ); if ( ! entity ) { diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index c8aec286a1107..b5ca3250534d7 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -104,7 +104,20 @@ export function getEntity( state, kind, name ) { * @return {Object?} Record. */ export function getEntityRecord( state, kind, name, key ) { - return get( state.entities.data, [ kind, name, 'queriedData', 'items', key ] ); + const record = get( state.entities.data, [ + kind, + name, + 'queriedData', + 'items', + key, + ] ); + return ( + record && + Object.keys( record ).reduce( ( acc, _key ) => { + acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] ); + return acc; + }, {} ) + ); } /** @@ -156,8 +169,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 +206,10 @@ export function hasEditsForEntityRecord( state, kind, name, recordId ) { * @return {Object?} The entity record, merged with its edits. */ export const getEditedEntityRecord = createSelector( - ( state, kind, name, recordId ) => { - const record = getEntityRecord( state, kind, name, recordId ); - return { - ...Object.keys( record ).reduce( ( acc, key ) => { - acc[ key ] = get( record[ key ], 'raw', record[ key ] ); - return acc; - }, {} ), - ...getEntityRecordEdits( state, kind, name, recordId ), - }; - }, + ( state, kind, name, recordId ) => ( { + ...getEntityRecord( state, kind, name, recordId ), + ...getEntityRecordEdits( state, kind, name, recordId ), + } ), ( state ) => [ state.entities.data ] ); @@ -237,12 +243,11 @@ export function isAutosavingEntityRecord( state, kind, name, recordId ) { * @return {boolean} Whether the entity record is saving or not. */ export function isSavingEntityRecord( state, kind, name, recordId ) { - const { pending, isAutosave } = get( + return get( state.entities.data, - [ kind, name, 'saving', recordId ], - {} + [ kind, name, 'saving', recordId, 'pending' ], + false ); - return Boolean( pending && ! isAutosave ); } /** diff --git a/packages/e2e-tests/plugins/meta-attribute-block/index.js b/packages/e2e-tests/plugins/meta-attribute-block/index.js index 0910e648299ae..c0d2d5a9bf775 100644 --- a/packages/e2e-tests/plugins/meta-attribute-block/index.js +++ b/packages/e2e-tests/plugins/meta-attribute-block/index.js @@ -7,25 +7,17 @@ icon: 'star', category: 'common', - attributes: { - content: { - type: "string", - source: "meta", - meta: "my_meta", - }, - }, - edit: function( props ) { - return el( - 'input', - { - className: 'my-meta-input', - value: props.attributes.content, - onChange: function( event ) { - props.setAttributes( { content: event.target.value } ); - }, - } - ); + var editEntity = wp.blockEditor.useEditEntity(); + return el( 'input', { + className: 'my-meta-input', + value: wp.blockEditor.useEditedEntityAttribute( 'meta' ).my_meta, + onChange: function( event ) { + editEntity( { + meta: { my_meta: event.target.value }, + } ); + }, + } ); }, save: function() { diff --git a/packages/e2e-tests/specs/change-detection.test.js b/packages/e2e-tests/specs/change-detection.test.js index 30895ce5590de..8900df4b3ea0d 100644 --- a/packages/e2e-tests/specs/change-detection.test.js +++ b/packages/e2e-tests/specs/change-detection.test.js @@ -151,6 +151,7 @@ describe( 'Change detection', () => { it( 'Should prompt if content added without save', async () => { await clickBlockAppender(); + await page.keyboard.type( 'Paragraph' ); await assertIsDirty( true ); } ); @@ -223,9 +224,9 @@ describe( 'Change detection', () => { // Keyboard shortcut Ctrl+S save. await pressKeyWithModifier( 'primary', 'S' ); - await releaseSaveIntercept(); - await assertIsDirty( true ); + + await releaseSaveIntercept(); } ); it( 'Should prompt if changes made while save is in-flight', async () => { @@ -240,6 +241,7 @@ describe( 'Change detection', () => { await pressKeyWithModifier( 'primary', 'S' ); await page.type( '.editor-post-title__input', '!' ); + await page.waitForSelector( '.editor-post-save-draft' ); await releaseSaveIntercept(); @@ -279,6 +281,7 @@ describe( 'Change detection', () => { await pressKeyWithModifier( 'primary', 'S' ); await clickBlockAppender(); + await page.keyboard.type( 'Paragraph' ); // Allow save to complete. Disabling interception flushes pending. await Promise.all( [ diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index 70a29b54466a1..d7a22a17184c2 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -8,7 +8,13 @@ import { size, map, without } from 'lodash'; * WordPress dependencies */ import { withSelect } from '@wordpress/data'; -import { EditorProvider, ErrorBoundary, PostLockedModal } from '@wordpress/editor'; +import { EntityProvider } from '@wordpress/block-editor'; +import { + entityProviderValue, + EditorProvider, + ErrorBoundary, + PostLockedModal, +} from '@wordpress/editor'; import { StrictMode, Component } from '@wordpress/element'; import { KeyboardShortcuts, @@ -97,20 +103,22 @@ class Editor extends Component { - - - - - - - - + + + + + + + + + + diff --git a/packages/editor/src/components/entity-handlers/index.js b/packages/editor/src/components/entity-handlers/index.js new file mode 100644 index 0000000000000..104375e3109c2 --- /dev/null +++ b/packages/editor/src/components/entity-handlers/index.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { EntityProvider, InnerBlocks } from '@wordpress/block-editor'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import EditorProvider from '../provider'; +import PostSavedState from '../post-saved-state'; + +export const entityProviderValue = { + useCurrentEntityAttribute( attribute ) { + return useSelect( + ( select ) => select( 'core/editor' ).getCurrentEntityAttribute( attribute ), + [] + ); + }, + useEditedEntityAttribute( attribute ) { + return useSelect( + ( select ) => select( 'core/editor' ).getEditedEntityAttribute( attribute ), + [] + ); + }, + useEditEntity() { + return useDispatch()( 'core/editor' ).editEntity; + }, +}; + +export default function EntityHandlers( { + entity, + handles = { all: true }, + children, + ...props +} ) { + const { editorSettings, parentEntity } = useSelect( + ( select ) => ( { + editorSettings: select( 'core/editor' ).getEditorSettings(), + parentEntity: select( 'core/editor' ).getCurrentPost(), + } ), + [] + ); + const parentDispatch = useDispatch(); + + // Using the provider like this will create a separate registry + // and store for the `entity`, while still syncing child blocks + // to the top level registry as inner blocks to maintain a + // seamless editing experience. + return ( + + ( { ...editorSettings, handles, parentDispatch } ), + [ editorSettings, parentDispatch ] + ) } + post={ entity || parentEntity } + { ...props } + > + + { children } + + + ); +} + +// Expose this so that saving content to the +// top level registry's entity is more semantic. +EntityHandlers.Content = InnerBlocks.Content; diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index f8999c7868042..9d643cbc903e8 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -13,6 +13,10 @@ export { default as TextEditorGlobalKeyboardShortcuts } from './global-keyboard- export { default as EditorHistoryRedo } from './editor-history/redo'; export { default as EditorHistoryUndo } from './editor-history/undo'; export { default as EditorNotices } from './editor-notices'; +export { + default as EntityHandlers, + entityProviderValue, +} from './entity-handlers'; export { default as ErrorBoundary } from './error-boundary'; export { default as PageAttributesCheck } from './page-attributes/check'; export { default as PageAttributesOrder } from './page-attributes/order'; diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index b11341e1a33f0..eeab453815cfe 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -11,7 +11,11 @@ import { compose } from '@wordpress/compose'; import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { BlockEditorProvider, transformStyles } from '@wordpress/block-editor'; +import { + transformStyles, + InnerBlocks, + BlockEditorProvider, +} from '@wordpress/block-editor'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; @@ -148,12 +152,50 @@ class EditorProvider extends Component { reusableBlocks, resetEditorBlocksWithoutUndoLevel, hasUploadPermissions, + noBlockEditorStore, + noInnerBlocks, } = this.props; if ( ! isReady ) { return null; } + // This indicates that this provider is nested in another. + // When this is the case, we want child blocks to sync to + // this provider's entity and to the top level's entity, as + // inner blocks. The `handles` prop of each provider will + // determine which properties, including the actual serialized + // content, get synced and persisted, or delegated to a parent provider. + if ( noBlockEditorStore ) { + // Handle content and blocks if all attributes are implicitly + // handled and they are not explicitly not handled, or if they + // are explicitly handled. + const innerBlocksProps = + ! noInnerBlocks && + settings.handles && + ( ( settings.handles.all && + settings.handles.content !== false && + settings.handles.blocks !== false ) || + ( settings.handles.content && settings.handles.blocks ) ) ? + { + blocks, + resetEditorBlocks, + resetEditorBlocksWithoutUndoLevel, + } : + {}; + return ( + <> + { children } + { /* + Inner blocks will use the top level registry's block-editor store, + but we provide props to sync with this provider's entity. Just + like how the block-editor store syncs with the top level editor store. + */ } + { ! noInnerBlocks && } + + ); + } + const editorSettings = this.getBlockEditorSettings( settings, reusableBlocks, diff --git a/packages/editor/src/components/provider/with-registry-provider.js b/packages/editor/src/components/provider/with-registry-provider.js index 367782a82b4a4..fa3408899165b 100644 --- a/packages/editor/src/components/provider/with-registry-provider.js +++ b/packages/editor/src/components/provider/with-registry-provider.js @@ -14,16 +14,26 @@ import applyMiddlewares from '../../store/middlewares'; const withRegistryProvider = createHigherOrderComponent( ( WrappedComponent ) => withRegistry( ( props ) => { - const { useSubRegistry = true, registry, ...additionalProps } = props; + const { + useSubRegistry = true, + noBlockEditorStore = false, + registry, + ...additionalProps + } = props; if ( ! useSubRegistry ) { return ; } const [ subRegistry, setSubRegistry ] = useState( null ); useEffect( () => { - const newRegistry = createRegistry( { - 'core/block-editor': blockEditorStoreConfig, - }, registry ); + const newRegistry = createRegistry( + noBlockEditorStore ? + {} : + { + 'core/block-editor': blockEditorStoreConfig, + }, + registry + ); const store = newRegistry.registerStore( 'core/editor', storeConfig ); // This should be removed after the refactoring of the effects to controls. applyMiddlewares( store ); @@ -36,7 +46,7 @@ const withRegistryProvider = createHigherOrderComponent( return ( - + ); } ), diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 36190a77b47ad..b76064de0ff5a 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,8 +1,8 @@ /** * External dependencies */ -import { castArray, pick, mapValues, has } from 'lodash'; -import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; +import { has, castArray } from 'lodash'; +import memoize from 'memize'; /** * WordPress dependencies @@ -12,141 +12,25 @@ import { dispatch, select, apiFetch } from '@wordpress/data-controls'; import { parse, synchronizeBlocksWithTemplate, + serialize, + isUnmodifiedDefaultBlock, + getFreeformContentHandlerName, } from '@wordpress/blocks'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import { removep } from '@wordpress/autop'; /** * Internal dependencies */ -import { - getPostRawValue, -} from './reducer'; import { STORE_KEY, POST_UPDATE_TRANSACTION_ID, - SAVE_POST_NOTICE_ID, TRASH_POST_NOTICE_ID, - AUTOSAVE_PROPERTIES, } from './constants'; import { getNotificationArgumentsForSaveSuccess, getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; -import { awaitNextStateChange, getRegistry } from './controls'; -import * as sources from './block-sources'; - -/** - * Map of Registry instance to WeakMap of dependencies by custom source. - * - * @type WeakMap> - */ -const lastBlockSourceDependenciesByRegistry = new WeakMap; - -/** - * Given a blocks array, returns a blocks array with sourced attribute values - * applied. The reference will remain consistent with the original argument if - * no attribute values must be overridden. If sourced values are applied, the - * return value will be a modified copy of the original array. - * - * @param {WPBlock[]} blocks Original blocks array. - * - * @return {WPBlock[]} Blocks array with sourced values applied. - */ -function* getBlocksWithSourcedAttributes( blocks ) { - const registry = yield getRegistry(); - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - return blocks; - } - - const blockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - - let workingBlocks = blocks; - for ( let i = 0; i < blocks.length; i++ ) { - let block = blocks[ i ]; - const blockType = yield select( 'core/blocks', 'getBlockType', block.name ); - - for ( const [ attributeName, schema ] of Object.entries( blockType.attributes ) ) { - if ( ! sources[ schema.source ] || ! sources[ schema.source ].apply ) { - continue; - } - - if ( ! blockSourceDependencies.has( sources[ schema.source ] ) ) { - continue; - } - - const dependencies = blockSourceDependencies.get( sources[ schema.source ] ); - const sourcedAttributeValue = sources[ schema.source ].apply( schema, dependencies ); - - // It's only necessary to apply the value if it differs from the - // block's locally-assigned value, to avoid needlessly resetting - // the block editor. - if ( sourcedAttributeValue === block.attributes[ attributeName ] ) { - continue; - } - - // Create a shallow clone to mutate, leaving the original intact. - if ( workingBlocks === blocks ) { - workingBlocks = [ ...workingBlocks ]; - } - - block = { - ...block, - attributes: { - ...block.attributes, - [ attributeName ]: sourcedAttributeValue, - }, - }; - - workingBlocks.splice( i, 1, block ); - } - - // Recurse to apply source attributes to inner blocks. - if ( block.innerBlocks.length ) { - const appliedInnerBlocks = yield* getBlocksWithSourcedAttributes( block.innerBlocks ); - if ( appliedInnerBlocks !== block.innerBlocks ) { - if ( workingBlocks === blocks ) { - workingBlocks = [ ...workingBlocks ]; - } - - block = { - ...block, - innerBlocks: appliedInnerBlocks, - }; - - workingBlocks.splice( i, 1, block ); - } - } - } - - return workingBlocks; -} - -/** - * Refreshes the last block source dependencies, optionally for a given subset - * of sources (defaults to the full set of sources). - * - * @param {?Array} sourcesToUpdate Optional subset of sources to reset. - * - * @yield {Object} Yielded actions or control descriptors. - */ -function* resetLastBlockSourceDependencies( sourcesToUpdate = Object.values( sources ) ) { - if ( ! sourcesToUpdate.length ) { - return; - } - - const registry = yield getRegistry(); - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); - } - - const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - - for ( const source of sourcesToUpdate ) { - const dependencies = yield* source.getDependencies(); - lastBlockSourceDependencies.set( source, dependencies ); - } -} /** * Returns an action generator used in signalling that editor has initialized with @@ -164,10 +48,10 @@ export function* setupEditor( post, edits, template ) { if ( has( edits, [ 'content' ] ) ) { content = edits.content; } else { - content = post.content.raw; + content = post.content; } - let blocks = parse( content ); + let blocks = content ? parse( content ) : []; // Apply a template for new posts only, if exists. const isNewPost = post.status === 'auto-draft'; @@ -176,16 +60,18 @@ export function* setupEditor( post, edits, template ) { } yield resetPost( post ); - yield* resetLastBlockSourceDependencies(); yield { type: 'SETUP_EDITOR', post, edits, template, }; - yield resetEditorBlocks( blocks ); + yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } ); yield setupEditorState( post ); - yield* __experimentalSubscribeSources(); + if ( edits ) { + const record = { ...post, ...edits }; + yield dispatch( 'core', 'receiveEntityRecords', post.kind, post.type, record ); + } } /** @@ -198,55 +84,6 @@ export function __experimentalTearDownEditor() { return { type: 'TEAR_DOWN_EDITOR' }; } -/** - * Returns an action generator which loops to await the next state change, - * calling to reset blocks when a block source dependencies change. - * - * @yield {Object} Action object. - */ -export function* __experimentalSubscribeSources() { - while ( true ) { - yield awaitNextStateChange(); - - // 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' ); - if ( ! isStillReady ) { - break; - } - - const registry = yield getRegistry(); - - let reset = false; - for ( const source of Object.values( sources ) ) { - if ( ! source.getDependencies ) { - continue; - } - - const dependencies = yield* source.getDependencies(); - - if ( ! lastBlockSourceDependenciesByRegistry.has( registry ) ) { - lastBlockSourceDependenciesByRegistry.set( registry, new WeakMap ); - } - - const lastBlockSourceDependencies = lastBlockSourceDependenciesByRegistry.get( registry ); - const lastDependencies = lastBlockSourceDependencies.get( source ); - - if ( ! isShallowEqual( dependencies, lastDependencies ) ) { - lastBlockSourceDependencies.set( source, dependencies ); - - // Allow the loop to continue in order to assign latest - // dependencies values, but mark for reset. - reset = true; - } - } - - if ( reset ) { - yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) ); - } - } -} - /** * Returns an action object used in signalling that the latest version of the * post has been received, either by initialization or save. @@ -286,7 +123,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 +132,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, }; } @@ -397,20 +181,40 @@ export function setupEditorState( post ) { } /** - * Returns an action object used in signalling that attributes of the post have + * Yields an action object used in signalling that attributes of the post have * been edited. * * @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 { handled, forParent, parentDispatch } = yield select( + STORE_KEY, + 'getHandlesFilteredEdits', + edits + ); + + if ( Object.keys( handled ).length ) { + const { kind, type, id } = yield select( 'core/editor', 'getCurrentPost' ); + yield dispatch( 'core', 'editEntityRecord', kind, type, id, handled ); + } + + if ( parentDispatch && Object.keys( forParent ).length ) { + parentDispatch( 'core/editor' ).editPost( forParent ); + } } +/** + * Yields an action object used in signalling that attributes of the entity have + * been edited. + * + * @param {Object} edits Entity attributes to edit. + * + * @yield {Object} Action object or control. + */ +export const editEntity = editPost; + /** * Returns action object produced by the updatePost creator augmented by * an optimist option that signals optimistically applying updates. @@ -432,175 +236,54 @@ 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' - ); - - let toSend = { - ...edits, - content: editedPostContent, - id: post.id, - }; - - const currentPostType = yield select( - STORE_KEY, - 'getCurrentPostType' - ); - - const postType = yield select( - 'core', - 'getPostType', - currentPostType - ); - - yield dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateStart', - options, - ); - - // Optimistically apply updates under the assumption that the post - // will be updated. See below logic in success resolution for revert - // if the autosave is applied as a revision. - yield dispatch( - STORE_KEY, - '__experimentalOptimisticUpdatePost', - toSend - ); - - 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, + const { + handled: { content: handlesContent }, + } = yield select( STORE_KEY, 'getHandlesFilteredEdits', { content: true } ); + if ( handlesContent ) { + yield dispatch( STORE_KEY, 'editPost', { + content: yield select( 'core/editor', 'getEditedPostContent' ), } ); - - if ( isAutosave ) { - yield dispatch( 'core', 'receiveAutosaves', post.id, newPost ); - } else { - yield dispatch( STORE_KEY, 'resetPost', newPost ); - } - - yield dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateSuccess', - { - previousPost: post, - post: newPost, - options, - postType, - // An autosave may be processed by the server as a regular save - // when its update is requested by the author and the post was - // draft or auto-draft. - isRevision: newPost.id !== post.id, - } - ); - - const notifySuccessArgs = getNotificationArgumentsForSaveSuccess( { - previousPost: post, - post: newPost, - postType, - options, - } ); - if ( notifySuccessArgs.length > 0 ) { - yield dispatch( - 'core/notices', - 'createSuccessNotice', - ...notifySuccessArgs - ); - } - } catch ( error ) { - yield dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateFailure', - { post, edits, error, options } - ); - const notifyFailArgs = getNotificationArgumentsForSaveFail( { - post, - edits, - error, - } ); - if ( notifyFailArgs.length > 0 ) { - yield dispatch( - 'core/notices', - 'createErrorNotice', - ...notifyFailArgs - ); - } } + + yield __experimentalRequestPostUpdateStart( options ); + const entityKind = yield select( STORE_KEY, 'getCurrentEntityKind' ); + const postType = yield select( 'core/editor', 'getCurrentPostType' ); + const postId = yield select( 'core/editor', 'getCurrentPostId' ); + const { handled } = yield select( STORE_KEY, 'getHandlesFilteredEdits' ); + const record = { id: postId, ...handled }; + const isPost = entityKind === 'postType'; + yield dispatch( 'core', 'saveEntityRecord', entityKind, postType, record, { + ...options, + getSuccessNoticeActionArgs: + isPost && + ( ( previousEntity, entity, type ) => { + const args = getNotificationArgumentsForSaveSuccess( { + previousPost: previousEntity, + post: entity, + postType: type, + options, + } ); + if ( args && args.length ) { + return [ 'core/notices', 'createSuccessNotice', ...args ]; + } + } ), + getFailureNoticeActionArgs: + isPost && + ( ( previousEntity, edits, error ) => { + const args = getNotificationArgumentsForSaveFail( { + post: previousEntity, + edits, + error, + } ); + if ( args && args.length ) { + return [ 'core/notices', 'createErrorNotice', ...args ]; + } + } ), + } ); + yield __experimentalRequestPostUpdateFinish( options ); } /** @@ -698,19 +381,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' ); } /** @@ -899,55 +582,54 @@ export function unlockPostSaving( lockName ) { } /** - * Returns an action object used to signal that the blocks have been updated. + * Serializes blocks following backwards compatibility conventions. * - * @param {Array} blocks Block Array. - * @param {?Object} options Optional options. + * @param {Array} blocksForSerialization The blocks to serialize. * - * @return {Object} Action object + * @return {string} The blocks serialization. */ -export function* resetEditorBlocks( blocks, options = {} ) { - const lastBlockAttributesChange = yield select( 'core/block-editor', '__experimentalGetLastBlockAttributeChanges' ); - - // Sync to sources from block attributes updates. - if ( lastBlockAttributesChange ) { - const updatedSources = new Set; - const updatedBlockTypes = new Set; - for ( const [ clientId, attributes ] of Object.entries( lastBlockAttributesChange ) ) { - const blockName = yield select( 'core/block-editor', 'getBlockName', clientId ); - if ( updatedBlockTypes.has( blockName ) ) { - continue; - } - - updatedBlockTypes.add( blockName ); - const blockType = yield select( 'core/blocks', 'getBlockType', blockName ); - - for ( const [ attributeName, newAttributeValue ] of Object.entries( attributes ) ) { - if ( ! blockType.attributes.hasOwnProperty( attributeName ) ) { - continue; - } +export const serializeBlocks = memoize( + ( blocksForSerialization ) => { + // A single unmodified default block is assumed to + // be equivalent to an empty post. + if ( + blocksForSerialization.length === 1 && + isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] ) + ) { + blocksForSerialization = []; + } - const schema = blockType.attributes[ attributeName ]; - const source = sources[ schema.source ]; + let content = serialize( blocksForSerialization ); - if ( source && source.update ) { - yield* source.update( schema, newAttributeValue ); - updatedSources.add( source ); - } - } + // 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 ); } - // Dependencies are reset so that source dependencies subscription - // skips a reset which would otherwise occur by dependencies change. - // This assures that at most one reset occurs per block change. - yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) ); - } + return content; + }, + { maxSize: 1 } +); - return { - type: 'RESET_EDITOR_BLOCKS', - blocks: yield* getBlocksWithSourcedAttributes( blocks ), - shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false, - }; +/** + * Returns an action object used to signal that the blocks have been updated. + * + * @param {Array} blocks Block Array. + * @param {?Object} options Optional options. + * + * @yield {Object} Action object + */ +export function* resetEditorBlocks( blocks, options = {} ) { + const edits = { blocks }; + if ( options.__unstableShouldCreateUndoLevel !== false ) { + edits.content = serializeBlocks( edits.blocks ); + } + yield* editPost( edits ); } /* diff --git a/packages/editor/src/store/block-sources/README.md b/packages/editor/src/store/block-sources/README.md deleted file mode 100644 index 0c16d12b3159d..0000000000000 --- a/packages/editor/src/store/block-sources/README.md +++ /dev/null @@ -1,22 +0,0 @@ -Block Sources -============= - -By default, the blocks module supports only attributes serialized into a block's comment demarcations, or those sourced from a [standard set of sources](https://developer.wordpress.org/block-editor/developers/block-api/block-attributes/). Since the blocks module is intended to be used in a number of contexts outside the post editor, the implementation of additional context-specific sources must be implemented as an external process. - -The post editor supports such additional sources for attributes (e.g. `meta` source). - -These sources are implemented here using a uniform interface for applying and responding to block updates to sourced attributes. In the future, this interface may be generalized to allow third-party extensions to either extend the post editor sources or implement their own in custom renderings of a block editor. - -## Source API - -### `getDependencies` - -Store control called on every store change, expected to return an object whose values represent the data blocks assigned this source depend on. When these values change, all blocks assigned this source are automatically updated. The value returned from this function is passed as the second argument of the source's `apply` function, where it is expected to be used as shared data relevant for sourcing the attribute value. - -### `apply` - -Function called to retrieve an attribute value for a block. Given the attribute schema and the dependencies defined by the source's `getDependencies`, the function should return the expected attribute value. - -### `update` - -Store control called when a single block's attributes have been updated, before the new block value has taken effect (i.e. before `apply` and `applyAll` are once again called). Given the attribute schema and updated value, the control should reflect the update on the source. diff --git a/packages/editor/src/store/block-sources/__mocks__/index.js b/packages/editor/src/store/block-sources/__mocks__/index.js deleted file mode 100644 index cb0ff5c3b541f..0000000000000 --- a/packages/editor/src/store/block-sources/__mocks__/index.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/editor/src/store/block-sources/index.js b/packages/editor/src/store/block-sources/index.js deleted file mode 100644 index 542d774c313ce..0000000000000 --- a/packages/editor/src/store/block-sources/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Internal dependencies - */ -import * as meta from './meta'; - -export { meta }; diff --git a/packages/editor/src/store/block-sources/meta.js b/packages/editor/src/store/block-sources/meta.js deleted file mode 100644 index 3910395c4a740..0000000000000 --- a/packages/editor/src/store/block-sources/meta.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * WordPress dependencies - */ -import { select } from '@wordpress/data-controls'; - -/** - * Internal dependencies - */ -import { editPost } from '../actions'; - -/** - * Store control invoked upon a state change, responsible for returning an - * object of dependencies. When a change in dependencies occurs (by shallow - * equality of the returned object), blocks are reset to apply the new sourced - * value. - * - * @yield {Object} Optional yielded controls. - * - * @return {Object} Dependencies as object. - */ -export function* getDependencies() { - return { - meta: yield select( 'core/editor', 'getEditedPostAttribute', 'meta' ), - }; -} - -/** - * Given an attribute schema and dependencies data, returns a source value. - * - * @param {Object} schema Block type attribute schema. - * @param {Object} dependencies Source dependencies. - * @param {Object} dependencies.meta Post meta. - * - * @return {Object} Block attribute value. - */ -export function apply( schema, { meta } ) { - return meta[ schema.meta ]; -} - -/** - * Store control invoked upon a block attributes update, responsible for - * reflecting an update in a meta value. - * - * @param {Object} schema Block type attribute schema. - * @param {*} value Updated block attribute value. - * - * @yield {Object} Yielded action objects or store controls. - */ -export function* update( schema, value ) { - yield editPost( { - meta: { - [ schema.meta ]: value, - }, - } ); -} diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index 07c92803bd0a1..f158a4664dec7 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -8,13 +8,6 @@ export const PREFERENCES_DEFAULTS = { isPublishSidebarEnabled: true, }; -/** - * Default initial edits state. - * - * @type {Object} - */ -export const INITIAL_EDITS_DEFAULTS = {}; - /** * The default post editor settings * diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index ef6ad6fd798c0..d55cb2cbf9d05 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,34 @@ 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 entityKind( 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 'RESET_POST': case 'UPDATE_POST': - return reduce( action.edits, ( result, value, key ) => { - if ( ! result.hasOwnProperty( key ) ) { - return result; - } + return action.post.kind; + } - result = getMutateSafeObject( state, result ); - delete result[ key ]; - return result; - }, state ); + return state; +} +export function postId( state, action ) { + switch ( action.type ) { + case 'SETUP_EDITOR_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 +190,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 +351,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 +394,14 @@ export function editorSettings( state = EDITOR_SETTINGS_DEFAULTS, action ) { } export default optimist( combineReducers( { - editor, - initialEdits, - currentPost, + entityKind, + 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 fa6843010ab70..acce245f2728c 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 './actions'; /** * 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 ); } @@ -113,23 +123,13 @@ export function hasChangedContent( state ) { * @return {boolean} Whether unsaved values exist. */ export function isEditedPostDirty( state ) { - if ( hasChangedContent( state ) ) { - return true; - } - // 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 ) { + if ( Object.keys( getHandlesFilteredEdits( state ).handled ).length ) { 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; } /** @@ -153,8 +153,32 @@ export function isCleanNewPost( state ) { * * @return {Object} Post object. */ -export function getCurrentPost( state ) { - return state.currentPost; +export const getCurrentPost = createRegistrySelector( ( select ) => ( state ) => { + const entityKind = getCurrentEntityKind( state ); + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + + const post = select( 'core' ).getEntityRecord( entityKind, 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 kind of the entity currently being edited, or null if the entity has + * not yet been saved. + * + * @param {Object} state Global application state. + * + * @return {?string} The entity kind. + */ +export function getCurrentEntityKind( state ) { + return state.entityKind; } /** @@ -165,11 +189,11 @@ export function getCurrentPost( state ) { * @return {string} Post type. */ export function getCurrentPostType( state ) { - return state.currentPost.type; + return state.postType; } /** - * Returns the ID of the post currently being edited, or null if the post has + * Returns the ID of the post currently being edited, or undefined if the post has * not yet been saved. * * @param {Object} state Global application state. @@ -177,7 +201,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 +235,12 @@ 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 entityKind = getCurrentEntityKind( state ); + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return select( 'core' ).getEntityRecordEdits( entityKind, postType, postId ) || EMPTY_OBJECT; +} ); /** * Returns a new reference when edited values have changed. This is useful in @@ -256,12 +274,33 @@ 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 ] ); } } +/** + * Returns an attribute value of the saved entity. + * + * @param {Object} state Global application state. + * @param {string} attributeName Entity attribute name. + * + * @return {*} Entity attribute value. + */ +export const getCurrentEntityAttribute = getCurrentPostAttribute; + /** * Returns a single attribute of the post being edited, preferring the unsaved * edit if one exists, but merging with the attribute value for the last known @@ -272,23 +311,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 @@ -322,6 +355,18 @@ export function getEditedPostAttribute( state, attributeName ) { return edits[ attributeName ]; } +/** + * Returns a single attribute of the entity being edited, preferring the unsaved + * edit if one exists, but falling back to the attribute for the last known + * saved state of the entity. + * + * @param {Object} state Global application state. + * @param {string} attributeName Entity attribute name. + * + * @return {*} Entity attribute value. + */ +export const getEditedEntityAttribute = getEditedPostAttribute; + /** * Returns an attribute value of the current autosave revision for a post, or * null if there is no autosave for the post. @@ -336,12 +381,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 +432,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,7 +522,7 @@ export function isEditedPostEmpty( state ) { // condition of the mere existence of blocks. Note that the value of edited // content takes precedent over block content, and must fall through to the // default logic. - const blocks = state.editor.present.blocks.value; + const blocks = getEditorBlocks( state ); if ( blocks.length && ! ( 'content' in getPostEdits( state ) ) ) { // Pierce the abstraction of the serializer in knowing that blocks are @@ -654,9 +698,12 @@ 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 entityKind = getCurrentEntityKind( state ); + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return select( 'core' ).isSavingEntityRecord( entityKind, postType, postId ); +} ); /** * Returns true if a previous post save was attempted successfully, or false @@ -666,9 +713,14 @@ 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 entityKind = getCurrentEntityKind( state ); + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return ! select( 'core' ).getLastEntitySaveError( entityKind, postType, postId ); + } +); /** * Returns true if a previous post save was attempted but failed, or false @@ -678,9 +730,14 @@ 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 entityKind = getCurrentEntityKind( state ); + const postType = getCurrentPostType( state ); + const postId = getCurrentPostId( state ); + return !! select( 'core' ).getLastEntitySaveError( entityKind, postType, postId ); + } +); /** * Returns true if the post is autosaving, or false otherwise. @@ -690,7 +747,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 +761,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 +775,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 +805,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 +848,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 +883,26 @@ 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 ); - } - - return content; - }, - ( state ) => [ - state.editor.present.blocks.value, - state.editor.present.edits.content, - state.initialEdits.content, - ], -); +export const getEditedPostContent = createRegistrySelector( ( select ) => ( state ) => { + const entityKind = getCurrentEntityKind( state ); + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + const record = select( 'core' ).getEditedEntityRecord( + entityKind, + postType, + postId + ); + if ( record ) { + return record.blocks ? serializeBlocks( record.blocks ) : record.content || ''; + } + return ''; +} ); /** * Returns the reusable block with the given ID. @@ -956,7 +1021,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 +1198,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; } /** @@ -1154,6 +1222,53 @@ export function getEditorSettings( state ) { return state.editorSettings; } +/** + * Returns an object with the edits broken down into a + * handled edits object, an edits object that should + * be delegated to a parent editor, and the parent's + * dispatching function, if any. + * + * @param {Object} state Editor state. + * @param {Object} _edits The edits, defaults + * to all of the entity's current edits. + * + * @return {Object} The object with the grouped edits and the + * parent's dispatching function. + */ +export const getHandlesFilteredEdits = createRegistrySelector( + ( select ) => ( state, edits ) => { + if ( ! edits ) { + const entityKind = getCurrentEntityKind( state ); + const postId = getCurrentPostId( state ); + const postType = getCurrentPostType( state ); + edits = select( 'core' ).getEntityRecordNonTransientEdits( + entityKind, + postType, + postId + ); + } + const { handles = { all: true }, parentDispatch } = getEditorSettings( state ); + return Object.keys( edits ).reduce( + ( acc, key ) => { + // Handle edits if all attributes are implicitly + // handled and they are not explicitly not handled, + // or if they are explicitly handled. + if ( ( handles.all && handles[ key ] !== false ) || handles[ key ] ) { + acc.handled[ key ] = edits[ key ]; + } else { + acc.forParent[ key ] = edits[ key ]; + } + return acc; + }, + { + handled: {}, + forParent: {}, + parentDispatch, + } + ); + } +); + /* * Backward compatibility */ diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index ecb588c091223..a1968fb6b606f 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,13 +9,11 @@ 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'; jest.mock( '@wordpress/data-controls' ); -jest.mock( '../block-sources' ); select.mockImplementation( ( ...args ) => { const { select: actualSelect } = jest @@ -59,36 +52,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,58 +68,13 @@ 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 ); @@ -159,261 +85,101 @@ describe( 'Post generator actions', () => { }, ], [ - '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' ) + 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 ); + const edits = { content: currentPost().content }; + const { value } = fulfillment.next( edits.content ); expect( value ).toEqual( - select( STORE_KEY, 'getCurrentPost' ) + 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' ) - ); + const { value } = fulfillment.next(); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_START', + options: { isAutosave }, + } ); }, ], [ - 'yields action for selecting current post type slug', + 'yields an action for selecting the current post type', () => true, () => { - const { value } = fulfillment.next( editedPostContent ); + const { value } = fulfillment.next(); expect( value ).toEqual( select( STORE_KEY, 'getCurrentPostType' ) ); }, ], [ - 'yields action for selecting the post type object', + 'yields an action for selecting the current post ID', () => true, () => { - const { value } = fulfillment.next( postTypeSlug ); + const { value } = fulfillment.next( currentPost().type ); expect( value ).toEqual( - select( 'core', 'getPostType', postTypeSlug ) + select( STORE_KEY, 'getCurrentPostId' ) ); }, ], [ - 'yields action for dispatching request post update start', + 'yields an action for dispatching an update to the post entity', () => true, () => { - const { value } = fulfillment.next( postType ); + const { value } = fulfillment.next( currentPost().id ); + value.args[ 3 ] = { + ...value.args[ 3 ], + getSuccessNoticeActionArgs: 'getSuccessNoticeActionArgs', + getFailureNoticeActionArgs: 'getFailureNoticeActionArgs', + }; expect( value ).toEqual( dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateStart', - { isAutosave } - ) - ); - }, - ], - [ - 'yields action for dispatching optimistic update of post', - () => true, - () => { - const { value } = fulfillment.next(); - expect( value ).toEqual( - dispatch( - STORE_KEY, - '__experimentalOptimisticUpdatePost', - editPostToSendOptimistic() - ) - ); - }, - ], - [ - 'yields action for dispatching the removal of save post notice', - ( isAutosaving ) => ! isAutosaving, - () => { - const { value } = fulfillment.next(); - expect( value ).toEqual( - dispatch( - 'core/notices', - 'removeNotice', - SAVE_POST_NOTICE_ID, - ) - ); - }, - ], - [ - 'yields action for dispatching the removal of autosave notice', - ( isAutosaving ) => ! isAutosaving, - () => { - const { value } = fulfillment.next(); - expect( value ).toEqual( - dispatch( - 'core/notices', - 'removeNotice', - 'autosave-exists' - ) - ); - }, - ], - [ - 'yields action for selecting the currentUser', - ( isAutosaving ) => isAutosaving, - () => { - const { value } = fulfillment.next(); - expect( value ).toEqual( - select( 'core', 'getCurrentUser' ) - ); - }, - ], - [ - 'yields action for selecting the autosavePost', - ( isAutosaving ) => isAutosaving, - () => { - const { value } = fulfillment.next( currentUser ); - expect( value ).toEqual( - select( 'core', - 'getAutosave', - postTypeSlug, - postId, - userId - ) - ); - }, - ], - ]; - const fetchErrorConditions = [ - [ - 'yields action for dispatching post update failure', - () => { - const error = { foo: 'bar', code: 'fail' }; - apiFetchThrowError( error ); - const editsObject = edits(); - const { value } = isAutosave ? - fulfillment.next( autoSavePost ) : - fulfillment.next(); - if ( isAutosave ) { - delete editsObject.foo; - } - expect( value ).toEqual( - dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateFailure', - { - post: currentPost(), - edits: isEditedPostNew ? - { ...editsObject, status: 'draft' } : - editsObject, - error, - options: { isAutosave }, - } - ) - ); - }, - ], - [ - 'yields action for dispatching an appropriate error notice', - () => { - const { value } = fulfillment.next( [ 'foo', 'bar' ] ); - expect( value ).toEqual( - dispatch( - 'core/notices', - 'createErrorNotice', - ...[ 'Updating failed.', { id: 'SAVE_POST_NOTICE_ID' } ] - ) - ); - }, - ], - ]; - const fetchSuccessConditions = [ - [ - 'yields action for updating the post via the api', - () => { - apiFetchDoActual(); - rewind( isAutosave, isEditedPostNew ); - const { value } = isAutosave ? - fulfillment.next( autoSavePost ) : - fulfillment.next(); - const data = isAutosave ? - autoSavePostToSend() : - editPostToSendOptimistic(); - const path = isAutosave ? '/autosaves' : ''; - expect( value ).toEqual( - apiFetch( + 'saveEditedEntityRecord', + 'postType', + currentPost().type, + currentPost().id, { - path: `/wp/v2/${ postType.rest_base }/${ editPostToSendOptimistic().id }${ path }`, - method: isAutosave ? 'POST' : 'PUT', - data, + isAutosave, + getSuccessNoticeActionArgs: + 'getSuccessNoticeActionArgs', + getFailureNoticeActionArgs: + 'getFailureNoticeActionArgs', } ) ); }, ], [ - 'yields action for dispatch the appropriate reset action', - () => { - const { value } = fulfillment.next( savedPost() ); - - if ( isAutosave ) { - expect( value ).toEqual( dispatch( 'core', 'receiveAutosaves', postId, savedPost() ) ); - } else { - expect( value ).toEqual( dispatch( STORE_KEY, 'resetPost', savedPost() ) ); - } - }, - ], - [ - 'yields action for dispatching the post update success', + 'yields an action for signalling that an update to the post finished', + () => true, () => { const { value } = fulfillment.next(); - expect( value ).toEqual( - dispatch( - STORE_KEY, - '__experimentalRequestPostUpdateSuccess', - { - previousPost: currentPost(), - post: savedPost(), - options: { isAutosave }, - postType, - isRevision: false, - } - ) - ); + expect( value ).toEqual( { + type: 'REQUEST_POST_UPDATE_FINISH', + options: { isAutosave }, + } ); }, ], [ - '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 +196,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()', () => { @@ -637,7 +356,7 @@ describe( 'Post generator actions', () => { describe( 'Editor actions', () => { describe( 'setupEditor()', () => { - const post = { content: { raw: '' }, status: 'publish' }; + const post = { content: '', status: 'publish' }; let fulfillment; const reset = ( edits, template ) => fulfillment = actions @@ -658,18 +377,18 @@ describe( 'Editor actions', () => { const { value } = fulfillment.next(); expect( value ).toEqual( { type: 'SETUP_EDITOR', - post: { content: { raw: '' }, status: 'publish' }, + post: { content: '', status: 'publish' }, } ); } ); it( 'should yield action object for resetEditorBlocks', () => { 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' } + { content: '', status: 'publish' } ) ); } ); @@ -691,51 +410,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 +427,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 +466,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 d964c72c0f77a..3fc6461b34e53 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 6d10b7f863aea..2185f04c80764 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -20,10 +20,111 @@ import { RawHTML } from '@wordpress/element'; /** * Internal dependencies */ -import * as selectors from '../selectors'; +import { serializeBlocks } from '../actions'; +import * as _selectors from '../selectors'; import { PREFERENCES_DEFAULTS } from '../defaults'; import { POST_UPDATE_TRANSACTION_ID } from '../constants'; +const selectors = { ..._selectors }; +const selectorNames = Object.keys( selectors ); +selectorNames.forEach( ( name ) => { + selectors[ name ] = ( state, ...args ) => { + const select = () => ( { + getEntityRecord() { + return state.currentPost; + }, + + getEntityRecordEdits() { + const present = state.editor && state.editor.present; + let edits = present && present.edits; + + if ( state.initialEdits ) { + edits = { + ...state.initialEdits, + ...edits, + }; + } + + const { value: blocks, isDirty } = ( present && present.blocks ) || {}; + if ( blocks && isDirty !== false ) { + edits = { + ...edits, + blocks, + }; + } + + return edits; + }, + + hasEditsForEntityRecord() { + return Object.keys( this.getEntityRecordEdits() ).length > 0; + }, + + getEditedEntityRecord() { + let edits = this.getEntityRecordEdits(); + if ( edits.content === undefined && edits.blocks ) { + edits = { + ...edits, + content: serializeBlocks( edits.blocks ), + }; + } + return { + ...this.getEntityRecord(), + ...edits, + }; + }, + + isSavingEntityRecord() { + return state.saving && state.saving.requesting; + }, + + getLastEntitySaveError() { + const saving = state.saving; + const successful = saving && saving.successful; + const error = saving && saving.error; + return successful === undefined ? error : ! successful; + }, + + hasUndo() { + return Boolean( + state.editor && state.editor.past && state.editor.past.length + ); + }, + + hasRedo() { + return Boolean( + state.editor && state.editor.future && state.editor.future.length + ); + }, + + getCurrentUser() { + return state.getCurrentUser && state.getCurrentUser(); + }, + + hasFetchedAutosaves() { + return state.hasFetchedAutosaves && state.hasFetchedAutosaves(); + }, + + getAutosave() { + return state.getAutosave && state.getAutosave(); + }, + } ); + + selectorNames.forEach( ( otherName ) => { + if ( _selectors[ otherName ].isRegistrySelector ) { + _selectors[ otherName ].registry = { select }; + } + } ); + + return _selectors[ name ]( state, ...args ); + }; + selectors[ name ].isRegistrySelector = _selectors[ name ].isRegistrySelector; + if ( selectors[ name ].isRegistrySelector ) { + selectors[ name ].registry = { + select: () => _selectors[ name ].registry.select(), + }; + } +} ); const { hasEditorUndo, hasEditorRedo, @@ -44,7 +145,7 @@ const { isCurrentPostScheduled, isEditedPostPublishable, isEditedPostSaveable, - isEditedPostAutosaveable: _isEditedPostAutosaveableRegistrySelector, + isEditedPostAutosaveable, isEditedPostEmpty, isEditedPostBeingScheduled, isEditedPostDateFloating, @@ -71,14 +172,9 @@ const { describe( 'selectors', () => { let cachedSelectors; - let isEditedPostAutosaveableRegistrySelector; beforeAll( () => { cachedSelectors = filter( selectors, ( selector ) => selector.clear ); - isEditedPostAutosaveableRegistrySelector = ( select ) => { - _isEditedPostAutosaveableRegistrySelector.registry = { select }; - return _isEditedPostAutosaveableRegistrySelector; - }; } ); beforeEach( () => { @@ -354,37 +450,6 @@ describe( 'selectors', () => { expect( isEditedPostDirty( state ) ).toBe( true ); } ); - - it( 'should return true if pending transaction with dirty state', () => { - const state = { - optimist: [ - { - beforeState: { - editor: { - present: { - blocks: { - isDirty: true, - value: [], - }, - edits: {}, - }, - }, - }, - }, - ], - editor: { - present: { - blocks: { - isDirty: false, - value: [], - }, - edits: {}, - }, - }, - }; - - expect( isEditedPostDirty( state ) ).toBe( true ); - } ); } ); describe( 'isCleanNewPost', () => { @@ -471,7 +536,7 @@ describe( 'selectors', () => { describe( 'getCurrentPostId', () => { it( 'should return null if the post has not yet been saved', () => { const state = { - currentPost: {}, + postId: null, }; expect( getCurrentPostId( state ) ).toBeNull(); @@ -479,7 +544,7 @@ describe( 'selectors', () => { it( 'should return the current post ID', () => { const state = { - currentPost: { id: 1 }, + postId: 1, }; expect( getCurrentPostId( state ) ).toBe( 1 ); @@ -673,9 +738,7 @@ describe( 'selectors', () => { describe( 'getCurrentPostType', () => { it( 'should return the post type', () => { const state = { - currentPost: { - type: 'post', - }, + postType: 'post', }; expect( getCurrentPostType( state ) ).toBe( 'post' ); @@ -1272,18 +1335,6 @@ describe( 'selectors', () => { describe( 'isEditedPostAutosaveable', () => { it( 'should return false if existing autosaves have not yet been fetched', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return false; - }, - getAutosave() { - return { - title: 'sassel', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1300,24 +1351,21 @@ describe( 'selectors', () => { saving: { requesting: true, }, - }; - - expect( isEditedPostAutosaveable( state ) ).toBe( false ); - } ); - - it( 'should return false if the post is not saveable', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { getCurrentUser() {}, hasFetchedAutosaves() { - return true; + return false; }, getAutosave() { return { title: 'sassel', }; }, - } ) ); + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + it( 'should return false if the post is not saveable', () => { const state = { editor: { present: { @@ -1334,20 +1382,21 @@ describe( 'selectors', () => { saving: { requesting: true, }, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + title: 'sassel', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( false ); } ); it( 'should return true if there is no autosave', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() {}, - } ) ); - const state = { editor: { present: { @@ -1362,25 +1411,17 @@ describe( 'selectors', () => { title: 'sassel', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() {}, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); } ); it( 'should return false if none of title, excerpt, or content have changed', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() { - return { - title: 'foo', - excerpt: 'foo', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1397,13 +1438,6 @@ describe( 'selectors', () => { excerpt: 'foo', }, saving: {}, - }; - - expect( isEditedPostAutosaveable( state ) ).toBe( false ); - } ); - - it( 'should return true if content has changes', () => { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { getCurrentUser() {}, hasFetchedAutosaves() { return true; @@ -1414,8 +1448,12 @@ describe( 'selectors', () => { excerpt: 'foo', }; }, - } ) ); + }; + + expect( isEditedPostAutosaveable( state ) ).toBe( false ); + } ); + it( 'should return true if content has changes', () => { const state = { editor: { present: { @@ -1431,6 +1469,16 @@ describe( 'selectors', () => { excerpt: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + title: 'foo', + excerpt: 'foo', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); @@ -1439,19 +1487,6 @@ describe( 'selectors', () => { it( 'should return true if title or excerpt have changed', () => { for ( const variantField of [ 'title', 'excerpt' ] ) { for ( const constantField of without( [ 'title', 'excerpt' ], variantField ) ) { - const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( { - getCurrentUser() {}, - hasFetchedAutosaves() { - return true; - }, - getAutosave() { - return { - [ constantField ]: 'foo', - [ variantField ]: 'bar', - }; - }, - } ) ); - const state = { editor: { present: { @@ -1468,6 +1503,16 @@ describe( 'selectors', () => { content: 'foo', }, saving: {}, + getCurrentUser() {}, + hasFetchedAutosaves() { + return true; + }, + getAutosave() { + return { + [ constantField ]: 'foo', + [ variantField ]: 'bar', + }; + }, }; expect( isEditedPostAutosaveable( state ) ).toBe( true ); @@ -1546,7 +1591,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return true if blocks, but empty content edit', () => { + it( 'should return false if blocks, but empty content edit', () => { const state = { editor: { present: { @@ -1571,7 +1616,7 @@ describe( 'selectors', () => { }, }; - expect( isEditedPostEmpty( state ) ).toBe( true ); + expect( isEditedPostEmpty( state ) ).toBe( false ); } ); it( 'should return true if the post has an empty content property', () => { @@ -1593,7 +1638,7 @@ describe( 'selectors', () => { expect( isEditedPostEmpty( state ) ).toBe( true ); } ); - it( 'should return false if edits include a non-empty content property', () => { + it( 'should return true if edits include a non-empty content property, but blocks are empty', () => { const state = { editor: { present: { @@ -1609,7 +1654,7 @@ describe( 'selectors', () => { currentPost: {}, }; - expect( isEditedPostEmpty( state ) ).toBe( false ); + expect( isEditedPostEmpty( state ) ).toBe( true ); } ); it( 'should return true if empty classic block', () => { @@ -2102,7 +2147,7 @@ describe( 'selectors', () => { } ); } ); - it( 'defers to returning an edited post attribute', () => { + it( 'serializes blocks, if any', () => { const block = createBlock( 'core/block' ); const state = { @@ -2122,7 +2167,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/utils/with-change-detection/README.md b/packages/editor/src/utils/with-change-detection/README.md deleted file mode 100644 index 75f6061483f83..0000000000000 --- a/packages/editor/src/utils/with-change-detection/README.md +++ /dev/null @@ -1,41 +0,0 @@ -withChangeDetection -=================== - -`withChangeDetection` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking changes to reducer state over time. - -It does this based on the following assumptions: - -- The original reducer returns an object -- The original reducer returns a new reference only if a change has in-fact occurred - -Using these assumptions, the enhanced reducer returned from `withChangeDetection` will include a new property on the object `isDirty` corresponding to whether the original reference of the reducer has ever changed. - -Leveraging a `resetTypes` option, this can be used to mark intervals at which a state is considered to be clean (without changes) and dirty (with changes). - -## Example - -Considering a simple count reducer, we can enhance it with `withChangeDetection` to reflect whether changes have occurred: - -```js -function counter( state = { count: 0 }, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { ...state, count: state.count + 1 }; - } - - return state; -} - -const enhancedCounter = withChangeDetection( counter, { resetTypes: [ 'RESET' ] } ); - -let state; - -state = enhancedCounter( undefined, {} ); -// { count: 0, isDirty: false } - -state = enhancedCounter( state, { type: 'INCREMENT' } ); -// { count: 1, isDirty: true } - -state = enhancedCounter( state, { type: 'RESET' } ); -// { count: 1, isDirty: false } -``` diff --git a/packages/editor/src/utils/with-change-detection/index.js b/packages/editor/src/utils/with-change-detection/index.js deleted file mode 100644 index e86db9a9905d4..0000000000000 --- a/packages/editor/src/utils/with-change-detection/index.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * External dependencies - */ -import { includes } from 'lodash'; - -/** - * Higher-order reducer creator for tracking changes to state over time. The - * returned reducer will include a `isDirty` property on the object reflecting - * whether the original reference of the reducer has changed. - * - * @param {?Object} options Optional options. - * @param {?Array} options.ignoreTypes Action types upon which to skip check. - * @param {?Array} options.resetTypes Action types upon which to reset dirty. - * - * @return {Function} Higher-order reducer. - */ -const withChangeDetection = ( options = {} ) => ( reducer ) => { - return ( state, action ) => { - let nextState = reducer( state, action ); - - // Reset at: - // - Initial state - // - Reset types - const isReset = ( - state === undefined || - includes( options.resetTypes, action.type ) - ); - - const isChanging = state !== nextState; - - // If not intending to update dirty flag, return early and avoid clone. - if ( ! isChanging && ! isReset ) { - return state; - } - - // Avoid mutating state, unless it's already changing by original - // reducer and not initial. - if ( ! isChanging || state === undefined ) { - nextState = { ...nextState }; - } - - const isIgnored = includes( options.ignoreTypes, action.type ); - - if ( isIgnored ) { - // Preserve the original value if ignored. - nextState.isDirty = state.isDirty; - } else { - nextState.isDirty = ! isReset && isChanging; - } - - return nextState; - }; -}; - -export default withChangeDetection; diff --git a/packages/editor/src/utils/with-change-detection/test/index.js b/packages/editor/src/utils/with-change-detection/test/index.js deleted file mode 100644 index 06abccb50fc5b..0000000000000 --- a/packages/editor/src/utils/with-change-detection/test/index.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * External dependencies - */ -import deepFreeze from 'deep-freeze'; - -/** - * Internal dependencies - */ -import withChangeDetection from '../'; - -describe( 'withChangeDetection()', () => { - const initialState = deepFreeze( { count: 0 } ); - - function originalReducer( state = initialState, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { - count: state.count + 1, - }; - - case 'RESET_AND_CHANGE_REFERENCE': - return { - count: state.count, - }; - } - - return state; - } - - it( 'should respect original reducer behavior', () => { - const reducer = withChangeDetection()( originalReducer ); - - const state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - const nextState = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( nextState ).not.toBe( state ); - expect( nextState ).toEqual( { count: 1, isDirty: true } ); - } ); - - it( 'should allow reset types as option', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - state = reducer( deepFreeze( state ), { type: 'RESET' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); - - it( 'should allow ignore types as option', () => { - const reducer = withChangeDetection( { ignoreTypes: [ 'INCREMENT' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); - - it( 'should preserve isDirty into non-resetting non-reference-changing types', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - const afterState = reducer( deepFreeze( state ), {} ); - expect( afterState ).toEqual( { count: 1, isDirty: true } ); - expect( afterState ).toBe( state ); - } ); - - it( 'should maintain separate states', () => { - const reducer = withChangeDetection()( originalReducer ); - - let firstState; - - firstState = reducer( undefined, {} ); - expect( firstState ).toEqual( { count: 0, isDirty: false } ); - - const secondState = reducer( undefined, { type: 'INCREMENT' } ); - expect( secondState ).toEqual( { count: 1, isDirty: false } ); - - firstState = reducer( deepFreeze( firstState ), {} ); - expect( firstState ).toEqual( { count: 0, isDirty: false } ); - } ); - - it( 'should flag as not dirty even if reset type causes reference change', () => { - const reducer = withChangeDetection( { resetTypes: [ 'RESET_AND_CHANGE_REFERENCE' ] } )( originalReducer ); - - let state; - - state = reducer( undefined, {} ); - expect( state ).toEqual( { count: 0, isDirty: false } ); - - state = reducer( deepFreeze( state ), { type: 'INCREMENT' } ); - expect( state ).toEqual( { count: 1, isDirty: true } ); - - state = reducer( deepFreeze( state ), { type: 'RESET_AND_CHANGE_REFERENCE' } ); - expect( state ).toEqual( { count: 1, isDirty: false } ); - } ); -} ); diff --git a/packages/editor/src/utils/with-history/README.md b/packages/editor/src/utils/with-history/README.md deleted file mode 100644 index 3c90ed2d895be..0000000000000 --- a/packages/editor/src/utils/with-history/README.md +++ /dev/null @@ -1,42 +0,0 @@ -withHistory -=========== - -`withHistory` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking the history of a reducer state over time. The enhanced reducer returned from `withHistory` will return an object shape with properties `past`, `present`, and `future`. The `present` value maintains the current value of state returned from the original reducer. Past and future are respectively maintained as arrays of state values occurring previously and future (if history undone). - -Leveraging a `resetTypes` option, this can be used to mark intervals at which a state history should be reset, emptying the values of the `past` and `future` arrays. - -History can be adjusted by dispatching actions with type `UNDO` (reset to the previous state) and `REDO` (reset to the next state). - -## Example - -Considering a simple count reducer, we can enhance it with `withHistory` to track value over time: - -```js -function counter( state = { count: 0 }, action ) { - switch ( action.type ) { - case 'INCREMENT': - return { ...state, count: state.count + 1 }; - } - - return state; -} - -const enhancedCounter = withHistory( counter, { resetTypes: [ 'RESET' ] } ); - -let state; - -state = enhancedCounter( undefined, {} ); -// { past: [], present: 0, future: [] } - -state = enhancedCounter( state, { type: 'INCREMENT' } ); -// { past: [ 0 ], present: 1, future: [] } - -state = enhancedCounter( state, { type: 'UNDO' } ); -// { past: [], present: 0, future: [ 1 ] } - -state = enhancedCounter( state, { type: 'REDO' } ); -// { past: [ 0 ], present: 1, future: [] } - -state = enhancedCounter( state, { type: 'RESET' } ); -// { past: [], present: 1, future: [] } -``` diff --git a/packages/editor/src/utils/with-history/index.js b/packages/editor/src/utils/with-history/index.js deleted file mode 100644 index 665851e632bd3..0000000000000 --- a/packages/editor/src/utils/with-history/index.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * External dependencies - */ -import { overSome, includes, first, last, drop, dropRight } from 'lodash'; - -/** - * Default options for withHistory reducer enhancer. Refer to withHistory - * documentation for options explanation. - * - * @see withHistory - * - * @type {Object} - */ -const DEFAULT_OPTIONS = { - resetTypes: [], - ignoreTypes: [], - shouldOverwriteState: () => false, -}; - -/** - * Higher-order reducer creator which transforms the result of the original - * reducer into an object tracking its own history (past, present, future). - * - * @param {?Object} options Optional options. - * @param {?Array} options.resetTypes Action types upon which to - * clear past. - * @param {?Array} options.ignoreTypes Action types upon which to - * avoid history tracking. - * @param {?Function} options.shouldOverwriteState Function receiving last and - * current actions, returning - * boolean indicating whether - * present should be merged, - * rather than add undo level. - * - * @return {Function} Higher-order reducer. - */ -const withHistory = ( options = {} ) => ( reducer ) => { - options = { ...DEFAULT_OPTIONS, ...options }; - - // `ignoreTypes` is simply a convenience for `shouldOverwriteState` - options.shouldOverwriteState = overSome( [ - options.shouldOverwriteState, - ( action ) => includes( options.ignoreTypes, action.type ), - ] ); - - const initialState = { - past: [], - present: reducer( undefined, {} ), - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - }; - - const { - resetTypes = [], - shouldOverwriteState = () => false, - } = options; - - return ( state = initialState, action ) => { - const { past, present, future, lastAction, shouldCreateUndoLevel } = state; - const previousAction = lastAction; - - switch ( action.type ) { - case 'UNDO': - // Can't undo if no past. - if ( ! past.length ) { - return state; - } - - return { - past: dropRight( past ), - present: last( past ), - future: [ present, ...future ], - lastAction: null, - shouldCreateUndoLevel: false, - }; - case 'REDO': - // Can't redo if no future. - if ( ! future.length ) { - return state; - } - - return { - past: [ ...past, present ], - present: first( future ), - future: drop( future ), - lastAction: null, - shouldCreateUndoLevel: false, - }; - - case 'CREATE_UNDO_LEVEL': - return { - ...state, - lastAction: null, - shouldCreateUndoLevel: true, - }; - } - - const nextPresent = reducer( present, action ); - - if ( includes( resetTypes, action.type ) ) { - return { - past: [], - present: nextPresent, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - }; - } - - if ( present === nextPresent ) { - return state; - } - - let nextPast = past; - // The `lastAction` property is used to compare actions in the - // `shouldOverwriteState` option. If an action should be ignored, do not - // submit that action as the last action, otherwise the ability to - // compare subsequent actions will break. - let lastActionToSubmit = previousAction; - - if ( - shouldCreateUndoLevel || - ! past.length || - ! shouldOverwriteState( action, previousAction ) - ) { - nextPast = [ ...past, present ]; - lastActionToSubmit = action; - } - - return { - past: nextPast, - present: nextPresent, - future: [], - shouldCreateUndoLevel: false, - lastAction: lastActionToSubmit, - }; - }; -}; - -export default withHistory; diff --git a/packages/editor/src/utils/with-history/test/index.js b/packages/editor/src/utils/with-history/test/index.js deleted file mode 100644 index e383988864f77..0000000000000 --- a/packages/editor/src/utils/with-history/test/index.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Internal dependencies - */ -import withHistory from '../'; - -describe( 'withHistory', () => { - const counter = ( state = 0, { type } ) => ( - type === 'INCREMENT' ? state + 1 : state - ); - - it( 'should return a new reducer', () => { - const reducer = withHistory()( counter ); - - expect( typeof reducer ).toBe( 'function' ); - expect( reducer( undefined, {} ) ).toEqual( { - past: [], - present: 0, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should track history', () => { - const reducer = withHistory()( counter ); - - let state; - const action = { type: 'INCREMENT' }; - state = reducer( undefined, {} ); - state = reducer( state, action ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: action, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, action ); - - expect( state ).toEqual( { - past: [ 0, 1 ], - present: 2, - future: [], - lastAction: action, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should perform undo', () => { - const reducer = withHistory()( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - - expect( state ).toEqual( { - past: [], - present: 0, - future: [ 1 ], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should not perform undo on empty past', () => { - const reducer = withHistory()( counter ); - const state = reducer( undefined, {} ); - - expect( state ).toBe( reducer( state, { type: 'UNDO' } ) ); - } ); - - it( 'should perform redo', () => { - const reducer = withHistory()( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'UNDO' } ); - state = reducer( state, { type: 'REDO' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should not perform redo on empty future', () => { - const reducer = withHistory()( counter ); - const state = reducer( undefined, {} ); - - expect( state ).toBe( reducer( state, { type: 'REDO' } ) ); - } ); - - it( 'should reset history by options.resetTypes', () => { - const reducer = withHistory( { resetTypes: [ 'RESET_HISTORY' ] } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'RESET_HISTORY' } ); - - expect( state ).toEqual( { - past: [], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should ignore history by options.ignoreTypes', () => { - const reducer = withHistory( { ignoreTypes: [ 'INCREMENT' ] } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], // Needs at least one history - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should return same reference if state has not changed', () => { - const reducer = withHistory()( counter ); - const original = reducer( undefined, {} ); - const state = reducer( original, {} ); - - expect( state ).toBe( original ); - } ); - - it( 'should overwrite present state with option.shouldOverwriteState', () => { - const reducer = withHistory( { - shouldOverwriteState: ( { type } ) => type === 'INCREMENT', - } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should overwrite present state with option.shouldOverwriteState right after ignored action', () => { - const complexCounter = ( state = { count: 0 }, action ) => { - if ( action.type === 'INCREMENT' ) { - return { - ...state, - count: state.count + 1, - }; - } else if ( action.type === 'IGNORE' ) { - return { - ...state, - ignore: action.content, - }; - } - - return state; - }; - - const reducer = withHistory( { - shouldOverwriteState: ( action, previousAction ) => ( - previousAction && action.type === previousAction.type - ), - ignoreTypes: [ 'IGNORE' ], - } )( complexCounter ); - - let state = reducer( reducer( undefined, {} ), { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 1 }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'IGNORE', content: 'ignore' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 1, ignore: 'ignore' }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ { count: 0 } ], - present: { count: 2, ignore: 'ignore' }, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); - - it( 'should create undo level with option.shouldOverwriteState and CREATE_UNDO_LEVEL', () => { - const reducer = withHistory( { - shouldOverwriteState: ( { type } ) => type === 'INCREMENT', - } )( counter ); - - let state; - state = reducer( undefined, {} ); - state = reducer( state, { type: 'INCREMENT' } ); - state = reducer( state, { type: 'CREATE_UNDO_LEVEL' } ); - - expect( state ).toEqual( { - past: [ 0 ], - present: 1, - future: [], - lastAction: null, - shouldCreateUndoLevel: true, - } ); - - state = reducer( state, { type: 'INCREMENT' } ); - - expect( state ).toEqual( { - past: [ 0, 1 ], - present: 2, - future: [], - lastAction: { type: 'INCREMENT' }, - shouldCreateUndoLevel: false, - } ); - } ); -} );