diff --git a/docs/designers-developers/developers/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md
index 884d2cc3910eb8..905e9cc077847d 100644
--- a/docs/designers-developers/developers/data/data-core-editor.md
+++ b/docs/designers-developers/developers/data/data-core-editor.md
@@ -192,6 +192,8 @@ _Related_
# **getBlocksForSerialization**
+> **Deprecated** since Gutenberg 6.2.0.
+
Returns a set of blocks which are to be used in consideration of the post's
generated save content.
@@ -309,8 +311,7 @@ _Returns_
# **getEditedPostContent**
-Returns the content of the post being edited, preferring raw string edit
-before falling back to serialization of block state.
+Returns the content of the post being edited.
_Parameters_
@@ -740,6 +741,7 @@ Return true if the current post has already been published.
_Parameters_
- _state_ `Object`: Global application state.
+- _currentPost_ `?Object`: Explicit current post for bypassing registry selector.
_Returns_
@@ -1041,10 +1043,6 @@ _Parameters_
- _edits_ `Object`: Post attributes to edit.
-_Returns_
-
-- `Object`: Action object.
-
# **enablePublishSidebar**
Returns an action object used in signalling that the user has enabled the
@@ -1178,10 +1176,6 @@ _Related_
Returns an action object used in signalling that undo history should
restore last popped state.
-_Returns_
-
-- `Object`: Action object.
-
# **refreshPost**
Action generator for handling refreshing the current post.
@@ -1240,10 +1234,6 @@ _Parameters_
- _blocks_ `Array`: Block Array.
- _options_ `?Object`: Optional options.
-_Returns_
-
-- `Object`: Action object
-
# **resetPost**
Returns an action object used in signalling that the latest version of the
@@ -1357,10 +1347,6 @@ Action generator for trashing the current post in the editor.
Returns an action object used in signalling that undo history should pop.
-_Returns_
-
-- `Object`: Action object.
-
# **unlockPostSaving**
Returns an action object used to signal that post saving is unlocked.
diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md
index 90db77965f5995..658ba7dc930213 100644
--- a/docs/designers-developers/developers/data/data-core.md
+++ b/docs/designers-developers/developers/data/data-core.md
@@ -217,6 +217,22 @@ _Returns_
- `?Object`: The entity record's save error.
+# **getRawEntityRecord**
+
+Returns the entity's record object by key,
+with its attributes mapped to their raw values.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _key_ `number`: Record's key.
+
+_Returns_
+
+- `?Object`: Object with the entity's raw attributes.
+
# **getRedoEdit**
Returns the next edit from the current undo offset
diff --git a/lib/blocks.php b/lib/blocks.php
index cf66e1d4044a89..b79745e31b17c9 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -46,6 +46,26 @@ function gutenberg_reregister_core_block_types() {
}
add_action( 'init', 'gutenberg_reregister_core_block_types' );
+/**
+ * Adds new block categories needed by the Gutenberg plugin.
+ *
+ * @param array $categories List of block categories.
+ *
+ * @return array List of block categories with the new categories added.
+ */
+function gutenberg_filter_block_categories( $categories ) {
+ return array_merge(
+ $categories,
+ array(
+ array(
+ 'slug' => 'theme',
+ 'title' => __( 'Theme Blocks', 'gutenberg' ),
+ ),
+ )
+ );
+}
+add_filter( 'block_categories', 'gutenberg_filter_block_categories' );
+
/**
* Registers a new block style.
*
diff --git a/package-lock.json b/package-lock.json
index 2febb418532ae1..5ee6b636dcf9a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4761,6 +4761,7 @@
"@wordpress/api-fetch": "file:packages/api-fetch",
"@wordpress/data": "file:packages/data",
"@wordpress/deprecated": "file:packages/deprecated",
+ "@wordpress/element": "file:packages/element",
"@wordpress/url": "file:packages/url",
"equivalent-key-map": "^0.2.2",
"lodash": "^4.17.14",
diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js
index 126e8c294fa1b7..d61ee52ae7fa09 100644
--- a/packages/block-editor/src/components/block-list/block.js
+++ b/packages/block-editor/src/components/block-list/block.js
@@ -691,6 +691,7 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => {
replaceBlocks,
toggleSelection,
setNavigationMode,
+ __unstableMarkLastChangeAsPersistent,
} = dispatch( 'core/block-editor' );
return {
@@ -744,6 +745,12 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, { select } ) => {
}
},
onReplace( blocks, indexToSelect ) {
+ if (
+ blocks.length &&
+ ! isUnmodifiedDefaultBlock( blocks[ blocks.length - 1 ] )
+ ) {
+ __unstableMarkLastChangeAsPersistent();
+ }
replaceBlocks( [ ownProps.clientId ], blocks, indexToSelect );
},
onShiftSelection() {
diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js
index 0d1feef14ff39d..c098d8fb5dafd3 100644
--- a/packages/block-editor/src/components/inner-blocks/index.js
+++ b/packages/block-editor/src/components/inner-blocks/index.js
@@ -44,7 +44,8 @@ class InnerBlocks extends Component {
}
componentDidMount() {
- const { innerBlocks } = this.props.block;
+ const { block, value, replaceInnerBlocks } = this.props;
+ const { innerBlocks } = block;
// only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists
if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) {
this.synchronizeBlocksWithTemplate();
@@ -55,10 +56,21 @@ class InnerBlocks extends Component {
templateInProcess: false,
} );
}
+
+ // Set controlled blocks value from parent, if any.
+ if ( value ) {
+ replaceInnerBlocks( value );
+ }
}
componentDidUpdate( prevProps ) {
- const { template, block } = this.props;
+ const {
+ block,
+ template,
+ isLastBlockChangePersistent,
+ onInput,
+ onChange,
+ } = this.props;
const { innerBlocks } = block;
this.updateNestedSettings();
@@ -69,6 +81,14 @@ class InnerBlocks extends Component {
this.synchronizeBlocksWithTemplate();
}
}
+
+ // Sync with controlled blocks value from parent, if possible.
+ if ( prevProps.block.innerBlocks !== innerBlocks ) {
+ const resetFunc = isLastBlockChangePersistent ? onInput : onChange;
+ if ( resetFunc ) {
+ resetFunc( innerBlocks );
+ }
+ }
}
/**
@@ -151,6 +171,7 @@ InnerBlocks = compose( [
getBlockListSettings,
getBlockRootClientId,
getTemplateLock,
+ isLastBlockChangePersistent,
} = select( 'core/block-editor' );
const { clientId } = ownProps;
const block = getBlock( clientId );
@@ -161,6 +182,7 @@ InnerBlocks = compose( [
blockListSettings: getBlockListSettings( clientId ),
hasOverlay: block.name !== 'core/template' && ! isBlockSelected( clientId ) && ! hasSelectedInnerBlock( clientId, true ),
parentLock: getTemplateLock( rootClientId ),
+ isLastBlockChangePersistent: isLastBlockChangePersistent(),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 67d460b4b4b404..fccce3433d132a 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -62,6 +62,11 @@ import * as tagCloud from './tag-cloud';
import * as classic from './classic';
+// Custom Entity Blocks
+import * as post from './post';
+import * as postTitle from './post-title';
+import * as postContent from './post-content';
+
/**
* Function to register an individual block.
*
@@ -138,6 +143,11 @@ export const registerCoreBlocks = () => {
textColumns,
verse,
video,
+
+ // Register Custom Entity Blocks.
+ post,
+ postTitle,
+ postContent,
].forEach( registerBlock );
setDefaultBlockName( paragraph.name );
diff --git a/packages/block-library/src/post-content/block.json b/packages/block-library/src/post-content/block.json
new file mode 100644
index 00000000000000..7472bd1b04c150
--- /dev/null
+++ b/packages/block-library/src/post-content/block.json
@@ -0,0 +1,4 @@
+{
+ "name": "core/post-content",
+ "category": "theme"
+}
diff --git a/packages/block-library/src/post-content/edit.js b/packages/block-library/src/post-content/edit.js
new file mode 100644
index 00000000000000..798744be0a8f82
--- /dev/null
+++ b/packages/block-library/src/post-content/edit.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityProp } from '@wordpress/core-data';
+import { useMemo, useCallback } from '@wordpress/element';
+import { parse } from '@wordpress/blocks';
+import { InnerBlocks } from '@wordpress/block-editor';
+import { serializeBlocks } from '@wordpress/editor';
+
+export default function PostContentEdit() {
+ const [ content, setContent ] = useEntityProp( 'post', 'content' );
+ const initialBlocks = useMemo( () => parse( content ), [] );
+ const [ blocks = initialBlocks, setBlocks ] = useEntityProp( 'post', 'blocks' );
+ return (
+ {
+ setContent( ( { blocks: blocksForSerialization = [] } ) =>
+ serializeBlocks( blocksForSerialization )
+ );
+ }, [] ) }
+ />
+ );
+}
diff --git a/packages/block-library/src/post-content/icon.js b/packages/block-library/src/post-content/icon.js
new file mode 100644
index 00000000000000..2cd26e9d5f5a31
--- /dev/null
+++ b/packages/block-library/src/post-content/icon.js
@@ -0,0 +1,11 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/components';
+
+export default (
+
+);
diff --git a/packages/block-library/src/post-content/index.js b/packages/block-library/src/post-content/index.js
new file mode 100644
index 00000000000000..76a1a07148094e
--- /dev/null
+++ b/packages/block-library/src/post-content/index.js
@@ -0,0 +1,20 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import icon from './icon';
+import edit from './edit';
+
+const { name } = metadata;
+export { metadata, name };
+
+export const settings = {
+ title: __( 'Post Content' ),
+ icon,
+ edit,
+};
diff --git a/packages/block-library/src/post-title/block.json b/packages/block-library/src/post-title/block.json
new file mode 100644
index 00000000000000..11d61129406b8a
--- /dev/null
+++ b/packages/block-library/src/post-title/block.json
@@ -0,0 +1,4 @@
+{
+ "name": "core/post-title",
+ "category": "theme"
+}
diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js
new file mode 100644
index 00000000000000..80b007a1774657
--- /dev/null
+++ b/packages/block-library/src/post-title/edit.js
@@ -0,0 +1,24 @@
+/**
+ * WordPress dependencies
+ */
+import { useEntityProp } from '@wordpress/core-data';
+import { RichText } from '@wordpress/block-editor';
+import { cleanForSlug } from '@wordpress/editor';
+import { __ } from '@wordpress/i18n';
+
+export default function PostTitleEdit() {
+ const [ title, setTitle ] = useEntityProp( 'post', 'title' );
+ const [ , setSlug ] = useEntityProp( 'post', 'slug' );
+ return (
+ {
+ setTitle( value );
+ setSlug( cleanForSlug( value ) );
+ } }
+ tagName="h1"
+ placeholder={ __( 'Title' ) }
+ formattingControls={ [] }
+ />
+ );
+}
diff --git a/packages/block-library/src/post-title/icon.js b/packages/block-library/src/post-title/icon.js
new file mode 100644
index 00000000000000..6dc60909619f82
--- /dev/null
+++ b/packages/block-library/src/post-title/icon.js
@@ -0,0 +1,11 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/components';
+
+export default (
+
+);
diff --git a/packages/block-library/src/post-title/index.js b/packages/block-library/src/post-title/index.js
new file mode 100644
index 00000000000000..ba834fe69b0384
--- /dev/null
+++ b/packages/block-library/src/post-title/index.js
@@ -0,0 +1,20 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import icon from './icon';
+import edit from './edit';
+
+const { name } = metadata;
+export { metadata, name };
+
+export const settings = {
+ title: __( 'Post Title' ),
+ icon,
+ edit,
+};
diff --git a/packages/block-library/src/post/block.json b/packages/block-library/src/post/block.json
new file mode 100644
index 00000000000000..a8a2be4fe2eb11
--- /dev/null
+++ b/packages/block-library/src/post/block.json
@@ -0,0 +1,9 @@
+{
+ "name": "core/post",
+ "category": "theme",
+ "attributes": {
+ "postId": {
+ "type": "number"
+ }
+ }
+}
diff --git a/packages/block-library/src/post/edit.js b/packages/block-library/src/post/edit.js
new file mode 100644
index 00000000000000..038d0552b31824
--- /dev/null
+++ b/packages/block-library/src/post/edit.js
@@ -0,0 +1,70 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useCallback } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import {
+ Placeholder,
+ TextControl,
+ Button,
+ Spinner,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { EntityProvider } from '@wordpress/core-data';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function PostEdit( { attributes: { postId }, setAttributes } ) {
+ const [ placeholderPostId, setPlaceholderPostId ] = useState();
+ const onPostIdSubmit = useCallback( ( event ) => {
+ event.preventDefault();
+
+ const value = event.currentTarget[ 0 ].value;
+ if ( ! value ) {
+ return;
+ }
+
+ setAttributes( { postId: Number( value ) } );
+ }, [] );
+
+ const entity = useSelect(
+ ( select ) =>
+ postId && select( 'core' ).getEntityRecord( 'postType', 'post', postId ),
+ [ postId ]
+ );
+
+ if ( ! postId ) {
+ return (
+
+
+
+ );
+ }
+
+ return entity ? (
+
+
+
+ ) : (
+
+
+
+ );
+}
diff --git a/packages/block-library/src/post/index.js b/packages/block-library/src/post/index.js
new file mode 100644
index 00000000000000..3d7ab2f8393b93
--- /dev/null
+++ b/packages/block-library/src/post/index.js
@@ -0,0 +1,20 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import edit from './edit';
+import save from './save';
+
+const { name } = metadata;
+export { metadata, name };
+
+export const settings = {
+ title: __( 'Post' ),
+ edit,
+ save,
+};
diff --git a/packages/block-library/src/post/save.js b/packages/block-library/src/post/save.js
new file mode 100644
index 00000000000000..039b6d8e9efb1f
--- /dev/null
+++ b/packages/block-library/src/post/save.js
@@ -0,0 +1,8 @@
+/**
+ * WordPress dependencies
+ */
+import { InnerBlocks } from '@wordpress/block-editor';
+
+export default function PostSave() {
+ return ;
+}
diff --git a/packages/block-library/src/post/style.scss b/packages/block-library/src/post/style.scss
new file mode 100644
index 00000000000000..f3c57fddbad860
--- /dev/null
+++ b/packages/block-library/src/post/style.scss
@@ -0,0 +1,11 @@
+.wp-block-post {
+ // Extra specificity to override default Placeholder styles.
+ &__placeholder-form.wp-block-post__placeholder-form {
+ align-items: center;
+ text-align: left;
+ }
+
+ &__placeholder-input {
+ width: 100px;
+ }
+}
diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss
index fd78fcc3b18d11..9443f2798fdf1a 100644
--- a/packages/block-library/src/style.scss
+++ b/packages/block-library/src/style.scss
@@ -23,6 +23,7 @@
@import "./text-columns/style.scss";
@import "./verse/style.scss";
@import "./video/style.scss";
+@import "./post/style.scss";
// The following selectors have increased specificity (using the :root prefix)
// to assure colors take effect over another base class color, mainly to let
diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js
index 405e60109adda3..64c44c3a37c8be 100644
--- a/packages/blocks/src/store/reducer.js
+++ b/packages/blocks/src/store/reducer.js
@@ -29,6 +29,7 @@ export const DEFAULT_CATEGORIES = [
{ slug: 'widgets', title: __( 'Widgets' ) },
{ slug: 'embed', title: __( 'Embeds' ) },
{ slug: 'reusable', title: __( 'Reusable Blocks' ) },
+ { slug: 'theme', title: __( 'Theme Blocks' ) },
];
/**
diff --git a/packages/core-data/README.md b/packages/core-data/README.md
index 64752e8ce030df..7d931ab20883eb 100644
--- a/packages/core-data/README.md
+++ b/packages/core-data/README.md
@@ -428,6 +428,22 @@ _Returns_
- `?Object`: The entity record's save error.
+# **getRawEntityRecord**
+
+Returns the entity's record object by key,
+with its attributes mapped to their raw values.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _key_ `number`: Record's key.
+
+_Returns_
+
+- `?Object`: Object with the entity's raw attributes.
+
# **getRedoEdit**
Returns the next edit from the current undo offset
diff --git a/packages/core-data/package.json b/packages/core-data/package.json
index 6f55b40cd39f69..b2dae8d258be35 100644
--- a/packages/core-data/package.json
+++ b/packages/core-data/package.json
@@ -26,6 +26,7 @@
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/data": "file:../data",
"@wordpress/deprecated": "file:../deprecated",
+ "@wordpress/element": "file:../element",
"@wordpress/url": "file:../url",
"equivalent-key-map": "^0.2.2",
"lodash": "^4.17.14",
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index 80d9fa1915b19a..b19aa86775b074 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -132,7 +132,7 @@ export function* editEntityRecord( kind, name, recordId, edits ) {
kind,
name
);
- const record = yield select( 'getEntityRecord', kind, name, recordId );
+ const record = yield select( 'getRawEntityRecord', kind, name, recordId );
const editedRecord = yield select(
'getEditedEntityRecord',
kind,
@@ -147,9 +147,9 @@ export function* editEntityRecord( kind, name, recordId, edits ) {
// Clear edits when they are equal to their persisted counterparts
// so that the property is not considered dirty.
edits: Object.keys( edits ).reduce( ( acc, key ) => {
- const recordValue = get( record[ key ], 'raw', record[ key ] );
+ const recordValue = record[ key ];
const value = mergedEdits[ key ] ?
- merge( recordValue, edits[ key ] ) :
+ merge( {}, recordValue, edits[ key ] ) :
edits[ key ];
acc[ key ] = isEqual( recordValue, value ) ? undefined : value;
return acc;
@@ -235,9 +235,10 @@ export function* saveEntityRecord(
let error;
try {
const path = `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`;
+
if ( isAutosave ) {
const persistedRecord = yield select(
- 'getEntityRecord',
+ 'getRawEntityRecord',
kind,
name,
recordId
@@ -255,18 +256,35 @@ export function* saveEntityRecord(
// to the actual persisted entity if the edits don't
// have a value.
let data = { ...persistedRecord, ...autosavePost, ...record };
- data = Object.keys( data ).reduce( ( acc, key ) => {
- if ( key in [ 'title', 'excerpt', 'content' ] ) {
- acc[ key ] = get( data[ key ], 'raw', data[ key ] );
- }
- return acc;
- }, {} );
+ data = Object.keys( data ).reduce(
+ ( acc, key ) => {
+ if ( [ 'title', 'excerpt', 'content' ].includes( key ) ) {
+ // Edits should be the "raw" attribute values.
+ acc[ key ] = get( data[ key ], 'raw', data[ key ] );
+ }
+ return acc;
+ },
+ { status: data.status === 'auto-draft' ? 'draft' : data.status }
+ );
updatedRecord = yield apiFetch( {
path: `${ path }/autosaves`,
method: 'POST',
data,
} );
- yield receiveAutosaves( persistedRecord.id, updatedRecord );
+ // An autosave may be processed by the server as a regular save
+ // when its update is requested by the author and the post had
+ // draft or auto-draft status.
+ if ( persistedRecord.id === updatedRecord.id ) {
+ yield receiveEntityRecords(
+ kind,
+ name,
+ { ...persistedRecord, ...data, ...updatedRecord },
+ undefined,
+ true
+ );
+ } else {
+ yield receiveAutosaves( persistedRecord.id, updatedRecord );
+ }
} else {
updatedRecord = yield apiFetch( {
path,
diff --git a/packages/core-data/src/entity-provider.js b/packages/core-data/src/entity-provider.js
new file mode 100644
index 00000000000000..bbb14563a58085
--- /dev/null
+++ b/packages/core-data/src/entity-provider.js
@@ -0,0 +1,71 @@
+/**
+ * WordPress dependencies
+ */
+import { createContext, useContext, useCallback } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { defaultEntities } from './entities';
+
+const entities = {
+ ...defaultEntities.reduce( ( acc, entity ) => {
+ acc[ entity.name ] = { kind: entity.kind, context: createContext() };
+ return acc;
+ }, {} ),
+ post: { kind: 'postType', context: createContext() },
+};
+
+/**
+ * Context provider component for providing
+ * an entity for a specific entity type.
+ *
+ * @param {number} id The entity ID.
+ * @param {string} type The entity type.
+ * @param {*} children The children to wrap.
+ *
+ * @return {Object} The provided children, wrapped with
+ * the entity's context provider.
+ */
+export default function EntityProvider( { id, type, children } ) {
+ const Provider = entities[ type ].context.Provider;
+ return { children };
+}
+
+/**
+ * Hook that returns the value and a setter for the
+ * specified property of the nearest provided
+ * entity of the specified type.
+ *
+ * @param {string} type The entity type.
+ * @param {string} prop The property name.
+ *
+ * @return {[*, Function]} A tuple where the first item is the
+ * property value and the second is the
+ * setter.
+ */
+export function useEntityProp( type, prop ) {
+ const kind = entities[ type ].kind;
+ const id = useContext( entities[ type ].context );
+
+ const value = useSelect(
+ ( select ) => {
+ const entity = select( 'core' ).getEditedEntityRecord( kind, type, id );
+ return entity && entity[ prop ];
+ },
+ [ kind, type, id, prop ]
+ );
+
+ const { editEntityRecord } = useDispatch( 'core' );
+ const setValue = useCallback(
+ ( newValue ) => {
+ editEntityRecord( kind, type, id, {
+ [ prop ]: newValue,
+ } );
+ },
+ [ kind, type, id, prop ]
+ );
+
+ return [ value, setValue ];
+}
diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js
index b0c5718ab9b888..2cdddb960e448b 100644
--- a/packages/core-data/src/index.js
+++ b/packages/core-data/src/index.js
@@ -48,3 +48,5 @@ registerStore( REDUCER_KEY, {
selectors: { ...selectors, ...entitySelectors },
resolvers: { ...resolvers, ...entityResolvers },
} );
+
+export { default as EntityProvider, useEntityProp } from './entity-provider';
diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js
index 7f0972e19e481b..48c3a6da889e84 100644
--- a/packages/core-data/src/reducer.js
+++ b/packages/core-data/src/reducer.js
@@ -167,6 +167,9 @@ function entity( entityConfig ) {
// If the edited value is still different to the persisted value,
// keep the edited value in edits.
if (
+ // Edits are the "raw" attribute values, but records may have
+ // objects with more properties, so we use `get` here for the
+ // comparison.
! isEqual( edits[ key ], get( record[ key ], 'raw', record[ key ] ) )
) {
acc[ key ] = edits[ key ];
@@ -330,18 +333,19 @@ export function undo( state = UNDO_INITIAL_STATE, action ) {
edits: { ...state.flattenedUndo, ...action.meta.undo.edits },
},
];
- } else {
- // Clear potential redos, because this only supports linear history.
- nextState = state.slice( 0, state.offset || undefined );
- nextState.flattenedUndo = state.flattenedUndo;
+ nextState.offset = 0;
+ return nextState;
}
+
+ // Clear potential redos, because this only supports linear history.
+ nextState = state.slice( 0, state.offset || undefined );
nextState.offset = 0;
nextState.push( {
kind: action.kind,
name: action.name,
recordId: action.recordId,
- edits: { ...nextState.flattenedUndo, ...action.edits },
+ edits: { ...action.edits, ...state.flattenedUndo },
} );
return nextState;
diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js
index 0f5cc1dd507809..317b758748720f 100644
--- a/packages/core-data/src/selectors.js
+++ b/packages/core-data/src/selectors.js
@@ -107,6 +107,34 @@ export function getEntityRecord( state, kind, name, key ) {
return get( state.entities.data, [ kind, name, 'queriedData', 'items', key ] );
}
+/**
+ * Returns the entity's record object by key,
+ * with its attributes mapped to their raw values.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} key Record's key.
+ *
+ * @return {Object?} Object with the entity's raw attributes.
+ */
+export const getRawEntityRecord = createSelector(
+ ( state, kind, name, key ) => {
+ const record = getEntityRecord( state, kind, name, key );
+ return (
+ record &&
+ Object.keys( record ).reduce( ( acc, _key ) => {
+ // Because edits are the "raw" attribute values,
+ // we return those from record selectors to make rendering,
+ // comparisons, and joins with edits easier.
+ acc[ _key ] = get( record[ _key ], 'raw', record[ _key ] );
+ return acc;
+ }, {} )
+ );
+ },
+ ( state ) => [ state.entities.data ]
+);
+
/**
* Returns the Entity's records.
*
@@ -156,8 +184,7 @@ export function getEntityRecordEdits( state, kind, name, recordId ) {
export const getEntityRecordNonTransientEdits = createSelector(
( state, kind, name, recordId ) => {
const { transientEdits = {} } = getEntity( state, kind, name );
- const edits =
- getEntityRecordEdits( state, kind, name, recordId ) || [];
+ const edits = getEntityRecordEdits( state, kind, name, recordId ) || [];
return Object.keys( edits ).reduce( ( acc, key ) => {
if ( ! transientEdits[ key ] ) {
acc[ key ] = edits[ key ];
@@ -194,16 +221,10 @@ export function hasEditsForEntityRecord( state, kind, name, recordId ) {
* @return {Object?} The entity record, merged with its edits.
*/
export const getEditedEntityRecord = createSelector(
- ( state, kind, name, recordId ) => {
- const record = getEntityRecord( state, kind, name, recordId );
- return {
- ...Object.keys( record ).reduce( ( acc, key ) => {
- acc[ key ] = get( record[ key ], 'raw', record[ key ] );
- return acc;
- }, {} ),
- ...getEntityRecordEdits( state, kind, name, recordId ),
- };
- },
+ ( state, kind, name, recordId ) => ( {
+ ...getRawEntityRecord( state, kind, name, recordId ),
+ ...getEntityRecordEdits( state, kind, name, recordId ),
+ } ),
( state ) => [ state.entities.data ]
);
@@ -237,12 +258,11 @@ export function isAutosavingEntityRecord( state, kind, name, recordId ) {
* @return {boolean} Whether the entity record is saving or not.
*/
export function isSavingEntityRecord( state, kind, name, recordId ) {
- const { pending, isAutosave } = get(
+ return get(
state.entities.data,
- [ kind, name, 'saving', recordId ],
- {}
+ [ kind, name, 'saving', recordId, 'pending' ],
+ false
);
- return Boolean( pending && ! isAutosave );
}
/**
diff --git a/packages/e2e-tests/specs/change-detection.test.js b/packages/e2e-tests/specs/change-detection.test.js
index 30895ce5590dea..85f1b5dd921620 100644
--- a/packages/e2e-tests/specs/change-detection.test.js
+++ b/packages/e2e-tests/specs/change-detection.test.js
@@ -133,14 +133,14 @@ describe( 'Change detection', () => {
await assertIsDirty( false );
} );
- it( 'Should not prompt to confirm unsaved changes for new post with initial edits', async () => {
+ it( 'Should prompt to confirm unsaved changes for new post with initial edits', async () => {
await createNewPost( {
title: 'My New Post',
content: 'My content',
excerpt: 'My excerpt',
} );
- await assertIsDirty( false );
+ await assertIsDirty( true );
} );
it( 'Should prompt if property changed without save', async () => {
@@ -151,6 +151,7 @@ describe( 'Change detection', () => {
it( 'Should prompt if content added without save', async () => {
await clickBlockAppender();
+ await page.keyboard.type( 'Paragraph' );
await assertIsDirty( true );
} );
@@ -223,9 +224,9 @@ describe( 'Change detection', () => {
// Keyboard shortcut Ctrl+S save.
await pressKeyWithModifier( 'primary', 'S' );
- await releaseSaveIntercept();
-
await assertIsDirty( true );
+
+ await releaseSaveIntercept();
} );
it( 'Should prompt if changes made while save is in-flight', async () => {
@@ -240,6 +241,7 @@ describe( 'Change detection', () => {
await pressKeyWithModifier( 'primary', 'S' );
await page.type( '.editor-post-title__input', '!' );
+ await page.waitForSelector( '.editor-post-save-draft' );
await releaseSaveIntercept();
@@ -279,6 +281,7 @@ describe( 'Change detection', () => {
await pressKeyWithModifier( 'primary', 'S' );
await clickBlockAppender();
+ await page.keyboard.type( 'Paragraph' );
// Allow save to complete. Disabling interception flushes pending.
await Promise.all( [
diff --git a/packages/e2e-tests/specs/demo.test.js b/packages/e2e-tests/specs/demo.test.js
index f5181a8a0e9683..9cffda7ed37bb1 100644
--- a/packages/e2e-tests/specs/demo.test.js
+++ b/packages/e2e-tests/specs/demo.test.js
@@ -28,12 +28,12 @@ describe( 'new editor state', () => {
await visitAdminPage( 'post-new.php', 'gutenberg-demo' );
} );
- it( 'content should load without making the post dirty', async () => {
+ it( 'content should load, making the post dirty', async () => {
const isDirty = await page.evaluate( () => {
const { select } = window.wp.data;
return select( 'core/editor' ).isEditedPostDirty();
} );
- expect( isDirty ).toBeFalsy();
+ expect( isDirty ).toBeTruthy();
} );
it( 'should be immediately saveable', async () => {
diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js
index be98bc2ef8bc67..78b58f919dc16d 100644
--- a/packages/editor/src/store/actions.js
+++ b/packages/editor/src/store/actions.js
@@ -1,38 +1,30 @@
/**
* External dependencies
*/
-import { castArray, pick, mapValues, has } from 'lodash';
-import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
+import { has, castArray } from 'lodash';
/**
* WordPress dependencies
*/
import deprecated from '@wordpress/deprecated';
import { dispatch, select, apiFetch } from '@wordpress/data-controls';
-import {
- parse,
- synchronizeBlocksWithTemplate,
-} from '@wordpress/blocks';
+import { parse, synchronizeBlocksWithTemplate } from '@wordpress/blocks';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
-import {
- getPostRawValue,
-} from './reducer';
import {
STORE_KEY,
POST_UPDATE_TRANSACTION_ID,
- SAVE_POST_NOTICE_ID,
TRASH_POST_NOTICE_ID,
- AUTOSAVE_PROPERTIES,
} from './constants';
import {
getNotificationArgumentsForSaveSuccess,
getNotificationArgumentsForSaveFail,
getNotificationArgumentsForTrashFail,
} from './utils/notice-builder';
+import serializeBlocks from './utils/serialize-blocks';
import { awaitNextStateChange, getRegistry } from './controls';
import * as sources from './block-sources';
@@ -183,8 +175,11 @@ export function* setupEditor( post, edits, template ) {
edits,
template,
};
- yield resetEditorBlocks( blocks );
+ yield resetEditorBlocks( blocks, { __unstableShouldCreateUndoLevel: false } );
yield setupEditorState( post );
+ if ( edits ) {
+ yield editPost( edits );
+ }
yield* __experimentalSubscribeSources();
}
@@ -210,7 +205,7 @@ export function* __experimentalSubscribeSources() {
// The bailout case: If the editor becomes unmounted, it will flag
// itself as non-ready. Effectively unsubscribes from the registry.
- const isStillReady = yield select( 'core/editor', '__unstableIsEditorReady' );
+ const isStillReady = yield select( STORE_KEY, '__unstableIsEditorReady' );
if ( ! isStillReady ) {
break;
}
@@ -242,7 +237,7 @@ export function* __experimentalSubscribeSources() {
}
if ( reset ) {
- yield resetEditorBlocks( yield select( 'core/editor', 'getEditorBlocks' ) );
+ yield resetEditorBlocks( yield select( STORE_KEY, 'getEditorBlocks' ), { __unstableShouldCreateUndoLevel: false } );
}
}
}
@@ -286,7 +281,7 @@ export function* resetAutosave( newAutosave ) {
}
/**
- * Optimistic action for dispatching that a post update request has started.
+ * Action for dispatching that a post update request has started.
*
* @param {Object} options
*
@@ -295,73 +290,20 @@ export function* resetAutosave( newAutosave ) {
export function __experimentalRequestPostUpdateStart( options = {} ) {
return {
type: 'REQUEST_POST_UPDATE_START',
- optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID },
options,
};
}
/**
- * Optimistic action for indicating that the request post update has completed
- * successfully.
- *
- * @param {Object} data The data for the action.
- * @param {Object} data.previousPost The previous post prior to update.
- * @param {Object} data.post The new post after update
- * @param {boolean} data.isRevision Whether the post is a revision or not.
- * @param {Object} data.options Options passed through from the original
- * action dispatch.
- * @param {Object} data.postType The post type object.
+ * Action for dispatching that a post update request has finished.
*
- * @return {Object} Action object.
- */
-export function __experimentalRequestPostUpdateSuccess( {
- previousPost,
- post,
- isRevision,
- options,
- postType,
-} ) {
- return {
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- previousPost,
- post,
- optimist: {
- // Note: REVERT is not a failure case here. Rather, it
- // is simply reversing the assumption that the updates
- // were applied to the post proper, such that the post
- // treated as having unsaved changes.
- type: isRevision ? REVERT : COMMIT,
- id: POST_UPDATE_TRANSACTION_ID,
- },
- options,
- postType,
- };
-}
-
-/**
- * Optimistic action for indicating that the request post update has completed
- * with a failure.
+ * @param {Object} options
*
- * @param {Object} data The data for the action
- * @param {Object} data.post The post that failed updating.
- * @param {Object} data.edits The fields that were being updated.
- * @param {*} data.error The error from the failed call.
- * @param {Object} data.options Options passed through from the original
- * action dispatch.
* @return {Object} An action object
*/
-export function __experimentalRequestPostUpdateFailure( {
- post,
- edits,
- error,
- options,
-} ) {
+export function __experimentalRequestPostUpdateFinish( options = {} ) {
return {
- type: 'REQUEST_POST_UPDATE_FAILURE',
- optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID },
- post,
- edits,
- error,
+ type: 'REQUEST_POST_UPDATE_FINISH',
options,
};
}
@@ -402,13 +344,11 @@ export function setupEditorState( post ) {
*
* @param {Object} edits Post attributes to edit.
*
- * @return {Object} Action object.
+ * @yield {Object} Action object or control.
*/
-export function editPost( edits ) {
- return {
- type: 'EDIT_POST',
- edits,
- };
+export function* editPost( edits ) {
+ const { id, type } = yield select( STORE_KEY, 'getCurrentPost' );
+ yield dispatch( 'core', 'editEntityRecord', 'postType', type, id, edits );
}
/**
@@ -432,173 +372,61 @@ export function __experimentalOptimisticUpdatePost( edits ) {
* @param {Object} options
*/
export function* savePost( options = {} ) {
- const isEditedPostSaveable = yield select(
- STORE_KEY,
- 'isEditedPostSaveable'
- );
- if ( ! isEditedPostSaveable ) {
+ if ( ! ( yield select( STORE_KEY, 'isEditedPostSaveable' ) ) ) {
return;
}
- let edits = yield select(
- STORE_KEY,
- 'getPostEdits'
- );
- const isAutosave = !! options.isAutosave;
-
- if ( isAutosave ) {
- edits = pick( edits, AUTOSAVE_PROPERTIES );
- }
-
- const isEditedPostNew = yield select(
- STORE_KEY,
- 'isEditedPostNew',
- );
-
- // New posts (with auto-draft status) must be explicitly assigned draft
- // status if there is not already a status assigned in edits (publish).
- // Otherwise, they are wrongly left as auto-draft. Status is not always
- // respected for autosaves, so it cannot simply be included in the pick
- // above. This behavior relies on an assumption that an auto-draft post
- // would never be saved by anyone other than the owner of the post, per
- // logic within autosaves REST controller to save status field only for
- // draft/auto-draft by current user.
- //
- // See: https://core.trac.wordpress.org/ticket/43316#comment:88
- // See: https://core.trac.wordpress.org/ticket/43316#comment:89
- if ( isEditedPostNew ) {
- edits = { status: 'draft', ...edits };
- }
-
- const post = yield select(
- STORE_KEY,
- 'getCurrentPost'
- );
-
- const editedPostContent = yield select(
- STORE_KEY,
- 'getEditedPostContent'
- );
+ yield dispatch( STORE_KEY, 'editPost', {
+ content: yield select( STORE_KEY, 'getEditedPostContent' ),
+ } );
- let toSend = {
- ...edits,
- content: editedPostContent,
- id: post.id,
+ yield __experimentalRequestPostUpdateStart( options );
+ const previousRecord = yield select( STORE_KEY, 'getCurrentPost' );
+ const edits = {
+ id: previousRecord.id,
+ ...( yield select(
+ 'core',
+ 'getEntityRecordNonTransientEdits',
+ 'postType',
+ previousRecord.type,
+ previousRecord.id
+ ) ),
};
-
- const currentPostType = yield select(
- STORE_KEY,
- 'getCurrentPostType'
- );
-
- const postType = yield select(
- 'core',
- 'getPostType',
- currentPostType
- );
-
yield dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateStart',
- options,
+ 'core',
+ 'saveEntityRecord',
+ 'postType',
+ previousRecord.type,
+ edits,
+ options
);
+ yield __experimentalRequestPostUpdateFinish( options );
- // Optimistically apply updates under the assumption that the post
- // will be updated. See below logic in success resolution for revert
- // if the autosave is applied as a revision.
- yield dispatch(
- STORE_KEY,
- '__experimentalOptimisticUpdatePost',
- toSend
+ const error = yield select(
+ 'core',
+ 'getLastEntitySaveError',
+ 'postType',
+ previousRecord.type,
+ previousRecord.id
);
-
- let path = `/wp/v2/${ postType.rest_base }/${ post.id }`;
- let method = 'PUT';
- if ( isAutosave ) {
- const currentUser = yield select( 'core', 'getCurrentUser' );
- const currentUserId = currentUser ? currentUser.id : undefined;
- const autosavePost = yield select( 'core', 'getAutosave', post.type, post.id, currentUserId );
- const mappedAutosavePost = mapValues( pick( autosavePost, AUTOSAVE_PROPERTIES ), getPostRawValue );
-
- // Ensure autosaves contain all expected fields, using autosave or
- // post values as fallback if not otherwise included in edits.
- toSend = {
- ...pick( post, AUTOSAVE_PROPERTIES ),
- ...mappedAutosavePost,
- ...toSend,
- };
- path += '/autosaves';
- method = 'POST';
- } else {
- yield dispatch(
- 'core/notices',
- 'removeNotice',
- SAVE_POST_NOTICE_ID
- );
- yield dispatch(
- 'core/notices',
- 'removeNotice',
- 'autosave-exists'
- );
- }
-
- try {
- const newPost = yield apiFetch( {
- path,
- method,
- data: toSend,
+ if ( error ) {
+ const args = getNotificationArgumentsForSaveFail( {
+ post: previousRecord,
+ edits,
+ error,
} );
-
- if ( isAutosave ) {
- yield dispatch( 'core', 'receiveAutosaves', post.id, newPost );
- } else {
- yield dispatch( STORE_KEY, 'resetPost', newPost );
+ if ( args.length ) {
+ yield dispatch( 'core/notices', 'createErrorNotice', ...args );
}
-
- yield dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateSuccess',
- {
- previousPost: post,
- post: newPost,
- options,
- postType,
- // An autosave may be processed by the server as a regular save
- // when its update is requested by the author and the post was
- // draft or auto-draft.
- isRevision: newPost.id !== post.id,
- }
- );
-
- const notifySuccessArgs = getNotificationArgumentsForSaveSuccess( {
- previousPost: post,
- post: newPost,
- postType,
+ } else {
+ const updatedRecord = yield select( STORE_KEY, 'getCurrentPost' );
+ const args = getNotificationArgumentsForSaveSuccess( {
+ previousPost: previousRecord,
+ post: updatedRecord,
+ postType: yield select( 'core', 'getPostType', updatedRecord.type ),
options,
} );
- if ( notifySuccessArgs.length > 0 ) {
- yield dispatch(
- 'core/notices',
- 'createSuccessNotice',
- ...notifySuccessArgs
- );
- }
- } catch ( error ) {
- yield dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateFailure',
- { post, edits, error, options }
- );
- const notifyFailArgs = getNotificationArgumentsForSaveFail( {
- post,
- edits,
- error,
- } );
- if ( notifyFailArgs.length > 0 ) {
- yield dispatch(
- 'core/notices',
- 'createErrorNotice',
- ...notifyFailArgs
- );
+ if ( args.length ) {
+ yield dispatch( 'core/notices', 'createSuccessNotice', ...args );
}
}
}
@@ -698,19 +526,19 @@ export function* autosave( options ) {
* Returns an action object used in signalling that undo history should
* restore last popped state.
*
- * @return {Object} Action object.
+ * @yield {Object} Action object.
*/
-export function redo() {
- return { type: 'REDO' };
+export function* redo() {
+ yield dispatch( 'core', 'redo' );
}
/**
* Returns an action object used in signalling that undo history should pop.
*
- * @return {Object} Action object.
+ * @yield {Object} Action object.
*/
-export function undo() {
- return { type: 'UNDO' };
+export function* undo() {
+ yield dispatch( 'core', 'undo' );
}
/**
@@ -878,7 +706,7 @@ export function disablePublishSidebar() {
* @example
* ```
* const { subscribe } = wp.data;
-
+ *
* const initialPostStatus = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' );
*
* // Only allow publishing posts that are set to a future date.
@@ -946,7 +774,7 @@ export function unlockPostSaving( lockName ) {
* @param {Array} blocks Block Array.
* @param {?Object} options Optional options.
*
- * @return {Object} Action object
+ * @yield {Object} Action object
*/
export function* resetEditorBlocks( blocks, options = {} ) {
const lastBlockAttributesChange = yield select( 'core/block-editor', '__experimentalGetLastBlockAttributeChanges' );
@@ -985,11 +813,17 @@ export function* resetEditorBlocks( blocks, options = {} ) {
yield* resetLastBlockSourceDependencies( Array.from( updatedSources ) );
}
- return {
- type: 'RESET_EDITOR_BLOCKS',
- blocks: yield* getBlocksWithSourcedAttributes( blocks ),
- shouldCreateUndoLevel: options.__unstableShouldCreateUndoLevel !== false,
- };
+ const edits = { blocks: yield* getBlocksWithSourcedAttributes( blocks ) };
+
+ if ( options.__unstableShouldCreateUndoLevel !== false ) {
+ // We create a new function here on every persistent edit
+ // to make sure the edit makes the post dirty and creates
+ // a new undo level.
+ edits.content = ( { blocks: blocksForSerialization = [] } ) =>
+ serializeBlocks( blocksForSerialization );
+ }
+
+ yield* editPost( edits );
}
/*
diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js
index 07c92803bd0a13..f158a4664dec7c 100644
--- a/packages/editor/src/store/defaults.js
+++ b/packages/editor/src/store/defaults.js
@@ -8,13 +8,6 @@ export const PREFERENCES_DEFAULTS = {
isPublishSidebarEnabled: true,
};
-/**
- * Default initial edits state.
- *
- * @type {Object}
- */
-export const INITIAL_EDITS_DEFAULTS = {};
-
/**
* The default post editor settings
*
diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js
index ef6ad6fd798c07..a88e05f30ac775 100644
--- a/packages/editor/src/store/reducer.js
+++ b/packages/editor/src/store/reducer.js
@@ -2,32 +2,17 @@
* External dependencies
*/
import optimist from 'redux-optimist';
-import {
- flow,
- reduce,
- omit,
- mapValues,
- keys,
- isEqual,
-} from 'lodash';
+import { reduce, omit, keys, isEqual } from 'lodash';
/**
* WordPress dependencies
*/
import { combineReducers } from '@wordpress/data';
-import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
-import {
- PREFERENCES_DEFAULTS,
- INITIAL_EDITS_DEFAULTS,
- EDITOR_SETTINGS_DEFAULTS,
-} from './defaults';
-import { EDIT_MERGE_PROPERTIES } from './constants';
-import withChangeDetection from '../utils/with-change-detection';
-import withHistory from '../utils/with-history';
+import { PREFERENCES_DEFAULTS, EDITOR_SETTINGS_DEFAULTS } from './defaults';
/**
* Returns a post attribute value, flattening nested rendered content using its
@@ -114,165 +99,23 @@ export function shouldOverwriteState( action, previousAction ) {
return isUpdatingSamePostProperty( action, previousAction );
}
-/**
- * Undoable reducer returning the editor post state, including blocks parsed
- * from current HTML markup.
- *
- * Handles the following state keys:
- * - edits: an object describing changes to be made to the current post, in
- * the format accepted by the WP REST API
- * - blocks: post content blocks
- *
- * @param {Object} state Current state.
- * @param {Object} action Dispatched action.
- *
- * @return {Object} Updated state.
- */
-export const editor = flow( [
- combineReducers,
-
- withHistory( {
- resetTypes: [ 'SETUP_EDITOR_STATE' ],
- ignoreTypes: [
- 'RESET_POST',
- 'UPDATE_POST',
- ],
- shouldOverwriteState,
- } ),
-] )( {
- // Track whether changes exist, resetting at each post save. Relies on
- // editor initialization firing post reset as an effect.
- blocks: withChangeDetection( {
- resetTypes: [ 'SETUP_EDITOR_STATE', 'REQUEST_POST_UPDATE_START' ],
- } )( ( state = { value: [] }, action ) => {
- switch ( action.type ) {
- case 'RESET_EDITOR_BLOCKS':
- if ( action.blocks === state.value ) {
- return state;
- }
- return { value: action.blocks };
- }
-
- return state;
- } ),
- edits( state = {}, action ) {
- switch ( action.type ) {
- case 'EDIT_POST':
- return reduce( action.edits, ( result, value, key ) => {
- // Only assign into result if not already same value
- if ( value !== state[ key ] ) {
- result = getMutateSafeObject( state, result );
-
- if ( EDIT_MERGE_PROPERTIES.has( key ) ) {
- // Merge properties should assign to current value.
- result[ key ] = { ...result[ key ], ...value };
- } else {
- // Otherwise override.
- result[ key ] = value;
- }
- }
-
- return result;
- }, state );
- case 'UPDATE_POST':
- case 'RESET_POST':
- const getCanonicalValue = action.type === 'UPDATE_POST' ?
- ( key ) => action.edits[ key ] :
- ( key ) => getPostRawValue( action.post[ key ] );
-
- return reduce( state, ( result, value, key ) => {
- if ( ! isEqual( value, getCanonicalValue( key ) ) ) {
- return result;
- }
-
- result = getMutateSafeObject( state, result );
- delete result[ key ];
- return result;
- }, state );
- case 'RESET_EDITOR_BLOCKS':
- if ( 'content' in state ) {
- return omit( state, 'content' );
- }
-
- return state;
- }
-
- return state;
- },
-} );
-
-/**
- * Reducer returning the initial edits state. With matching shape to that of
- * `editor.edits`, the initial edits are those applied programmatically, are
- * not considered in prompting the user for unsaved changes, and are included
- * in (and reset by) the next save payload.
- *
- * @param {Object} state Current state.
- * @param {Object} action Action object.
- *
- * @return {Object} Next state.
- */
-export function initialEdits( state = INITIAL_EDITS_DEFAULTS, action ) {
+export function postId( state = null, action ) {
switch ( action.type ) {
- case 'SETUP_EDITOR':
- if ( ! action.edits ) {
- break;
- }
-
- return action.edits;
-
case 'SETUP_EDITOR_STATE':
- if ( 'content' in state ) {
- return omit( state, 'content' );
- }
-
- return state;
-
- case 'UPDATE_POST':
- return reduce( action.edits, ( result, value, key ) => {
- if ( ! result.hasOwnProperty( key ) ) {
- return result;
- }
-
- result = getMutateSafeObject( state, result );
- delete result[ key ];
- return result;
- }, state );
-
case 'RESET_POST':
- return INITIAL_EDITS_DEFAULTS;
+ case 'UPDATE_POST':
+ return action.post.id;
}
return state;
}
-/**
- * Reducer returning the last-known state of the current post, in the format
- * returned by the WP REST API.
- *
- * @param {Object} state Current state.
- * @param {Object} action Dispatched action.
- *
- * @return {Object} Updated state.
- */
-export function currentPost( state = {}, action ) {
+export function postType( state = null, action ) {
switch ( action.type ) {
case 'SETUP_EDITOR_STATE':
case 'RESET_POST':
case 'UPDATE_POST':
- let post;
- if ( action.post ) {
- post = action.post;
- } else if ( action.edits ) {
- post = {
- ...state,
- ...action.edits,
- };
- } else {
- return state;
- }
-
- return mapValues( post, getPostRawValue );
+ return action.post.type;
}
return state;
@@ -336,26 +179,9 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) {
export function saving( state = {}, action ) {
switch ( action.type ) {
case 'REQUEST_POST_UPDATE_START':
+ case 'REQUEST_POST_UPDATE_FINISH':
return {
- requesting: true,
- successful: false,
- error: null,
- options: action.options || {},
- };
-
- case 'REQUEST_POST_UPDATE_SUCCESS':
- return {
- requesting: false,
- successful: true,
- error: null,
- options: action.options || {},
- };
-
- case 'REQUEST_POST_UPDATE_FAILURE':
- return {
- requesting: false,
- successful: false,
- error: action.error,
+ pending: action.type === 'REQUEST_POST_UPDATE_START',
options: action.options || {},
};
}
@@ -514,36 +340,6 @@ export const reusableBlocks = combineReducers( {
},
} );
-/**
- * Reducer returning the post preview link.
- *
- * @param {string?} state The preview link.
- * @param {Object} action Dispatched action.
- *
- * @return {string?} Updated state.
- */
-export function previewLink( state = null, action ) {
- switch ( action.type ) {
- case 'REQUEST_POST_UPDATE_SUCCESS':
- if ( action.post.preview_link ) {
- return action.post.preview_link;
- } else if ( action.post.link ) {
- return addQueryArgs( action.post.link, { preview: true } );
- }
-
- return state;
-
- case 'REQUEST_POST_UPDATE_START':
- // Invalidate known preview link when autosave starts.
- if ( state && action.options.isPreview ) {
- return null;
- }
- break;
- }
-
- return state;
-}
-
/**
* Reducer returning whether the editor is ready to be rendered.
* The editor is considered ready to be rendered once
@@ -587,15 +383,13 @@ export function editorSettings( state = EDITOR_SETTINGS_DEFAULTS, action ) {
}
export default optimist( combineReducers( {
- editor,
- initialEdits,
- currentPost,
+ postId,
+ postType,
preferences,
saving,
postLock,
reusableBlocks,
template,
- previewLink,
postSavingLock,
isReady,
editorSettings,
diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js
index fa6843010ab704..daf7adbd9ee2ae 100644
--- a/packages/editor/src/store/selectors.js
+++ b/packages/editor/src/store/selectors.js
@@ -16,13 +16,11 @@ import createSelector from 'rememo';
* WordPress dependencies
*/
import {
- serialize,
getFreeformContentHandlerName,
getDefaultBlockName,
isUnmodifiedDefaultBlock,
} from '@wordpress/blocks';
import { isInTheFuture, getDate } from '@wordpress/date';
-import { removep } from '@wordpress/autop';
import { addQueryArgs } from '@wordpress/url';
import { createRegistrySelector } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
@@ -39,6 +37,7 @@ import {
AUTOSAVE_PROPERTIES,
} from './constants';
import { getPostRawValue } from './reducer';
+import serializeBlocks from './utils/serialize-blocks';
/**
* Shared reference to an empty object for cases where it is important to avoid
@@ -49,6 +48,15 @@ import { getPostRawValue } from './reducer';
*/
const EMPTY_OBJECT = {};
+/**
+ * Shared reference to an empty array for cases where it is important to avoid
+ * returning a new array reference on every invocation, as in a connected or
+ * other pure component which performs `shouldComponentUpdate` check on props.
+ * This should be used as a last resort, since the normalized data should be
+ * maintained by the reducer result in state.
+ */
+const EMPTY_ARRAY = [];
+
/**
* Returns true if any past editor history snapshots exist, or false otherwise.
*
@@ -56,9 +64,9 @@ const EMPTY_OBJECT = {};
*
* @return {boolean} Whether undo history exists.
*/
-export function hasEditorUndo( state ) {
- return state.editor.past.length > 0;
-}
+export const hasEditorUndo = createRegistrySelector( ( select ) => () => {
+ return select( 'core' ).hasUndo();
+} );
/**
* Returns true if any future editor history snapshots exist, or false
@@ -68,9 +76,9 @@ export function hasEditorUndo( state ) {
*
* @return {boolean} Whether redo history exists.
*/
-export function hasEditorRedo( state ) {
- return state.editor.future.length > 0;
-}
+export const hasEditorRedo = createRegistrySelector( ( select ) => () => {
+ return select( 'core' ).hasRedo();
+} );
/**
* Returns true if the currently edited post is yet to be saved, or false if
@@ -92,15 +100,17 @@ export function isEditedPostNew( state ) {
* @return {boolean} Whether content includes unsaved changes.
*/
export function hasChangedContent( state ) {
+ const edits = getPostEdits( state );
+
return (
- state.editor.present.blocks.isDirty ||
+ 'blocks' in edits ||
// `edits` is intended to contain only values which are different from
// the saved post, so the mere presence of a property is an indicator
// that the value is different than what is known to be saved. While
// content in Visual mode is represented by the blocks state, in Text
// mode it is tracked by `edits.content`.
- 'content' in state.editor.present.edits
+ 'content' in edits
);
}
@@ -112,25 +122,17 @@ export function hasChangedContent( state ) {
*
* @return {boolean} Whether unsaved values exist.
*/
-export function isEditedPostDirty( state ) {
- if ( hasChangedContent( state ) ) {
- return true;
- }
-
+export const isEditedPostDirty = createRegistrySelector( ( select ) => ( state ) => {
// Edits should contain only fields which differ from the saved post (reset
// at initial load and save complete). Thus, a non-empty edits state can be
// inferred to contain unsaved values.
- if ( Object.keys( state.editor.present.edits ).length > 0 ) {
+ const postType = getCurrentPostType( state );
+ const postId = getCurrentPostId( state );
+ if ( select( 'core' ).hasEditsForEntityRecord( 'postType', postType, postId ) ) {
return true;
}
-
- // Edits and change detection are reset at the start of a save, but a post
- // is still considered dirty until the point at which the save completes.
- // Because the save is performed optimistically, the prior states are held
- // until committed. These can be referenced to determine whether there's a
- // chance that state may be reverted into one considered dirty.
- return inSomeHistory( state, isEditedPostDirty );
-}
+ return false;
+} );
/**
* Returns true if there are no unsaved values for the current edit session and
@@ -153,9 +155,20 @@ export function isCleanNewPost( state ) {
*
* @return {Object} Post object.
*/
-export function getCurrentPost( state ) {
- return state.currentPost;
-}
+export const getCurrentPost = createRegistrySelector( ( select ) => ( state ) => {
+ const postId = getCurrentPostId( state );
+ const postType = getCurrentPostType( state );
+
+ const post = select( 'core' ).getRawEntityRecord( 'postType', postType, postId );
+ if ( post ) {
+ return post;
+ }
+
+ // This exists for compatibility with the previous selector behavior
+ // which would guarantee an object return based on the editor reducer's
+ // default empty object state.
+ return EMPTY_OBJECT;
+} );
/**
* Returns the post type of the post currently being edited.
@@ -165,7 +178,7 @@ export function getCurrentPost( state ) {
* @return {string} Post type.
*/
export function getCurrentPostType( state ) {
- return state.currentPost.type;
+ return state.postType;
}
/**
@@ -177,7 +190,7 @@ export function getCurrentPostType( state ) {
* @return {?number} ID of current post.
*/
export function getCurrentPostId( state ) {
- return getCurrentPost( state ).id || null;
+ return state.postId;
}
/**
@@ -211,18 +224,11 @@ export function getCurrentPostLastRevisionId( state ) {
*
* @return {Object} Object of key value pairs comprising unsaved edits.
*/
-export const getPostEdits = createSelector(
- ( state ) => {
- return {
- ...state.initialEdits,
- ...state.editor.present.edits,
- };
- },
- ( state ) => [
- state.editor.present.edits,
- state.initialEdits,
- ]
-);
+export const getPostEdits = createRegistrySelector( ( select ) => ( state ) => {
+ const postType = getCurrentPostType( state );
+ const postId = getCurrentPostId( state );
+ return select( 'core' ).getEntityRecordEdits( 'postType', postType, postId ) || EMPTY_OBJECT;
+} );
/**
* Returns a new reference when edited values have changed. This is useful in
@@ -256,9 +262,20 @@ export const getReferenceByDistinctEdits = createSelector(
* @return {*} Post attribute value.
*/
export function getCurrentPostAttribute( state, attributeName ) {
- const post = getCurrentPost( state );
- if ( post.hasOwnProperty( attributeName ) ) {
- return post[ attributeName ];
+ switch ( attributeName ) {
+ case 'type':
+ return getCurrentPostType( state );
+
+ case 'id':
+ return getCurrentPostId( state );
+
+ default:
+ const post = getCurrentPost( state );
+ if ( ! post.hasOwnProperty( attributeName ) ) {
+ break;
+ }
+
+ return getPostRawValue( post[ attributeName ] );
}
}
@@ -272,23 +289,17 @@ export function getCurrentPostAttribute( state, attributeName ) {
*
* @return {*} Post attribute value.
*/
-const getNestedEditedPostProperty = createSelector(
- ( state, attributeName ) => {
- const edits = getPostEdits( state );
- if ( ! edits.hasOwnProperty( attributeName ) ) {
- return getCurrentPostAttribute( state, attributeName );
- }
+const getNestedEditedPostProperty = ( state, attributeName ) => {
+ const edits = getPostEdits( state );
+ if ( ! edits.hasOwnProperty( attributeName ) ) {
+ return getCurrentPostAttribute( state, attributeName );
+ }
- return {
- ...getCurrentPostAttribute( state, attributeName ),
- ...edits[ attributeName ],
- };
- },
- ( state, attributeName ) => [
- get( state.editor.present.edits, [ attributeName ], EMPTY_OBJECT ),
- get( state.currentPost, [ attributeName ], EMPTY_OBJECT ),
- ]
-);
+ return {
+ ...getCurrentPostAttribute( state, attributeName ),
+ ...edits[ attributeName ],
+ };
+};
/**
* Returns a single attribute of the post being edited, preferring the unsaved
@@ -336,12 +347,7 @@ export function getEditedPostAttribute( state, attributeName ) {
* @return {*} Autosave attribute value.
*/
export const getAutosaveAttribute = createRegistrySelector( ( select ) => ( state, attributeName ) => {
- deprecated( '`wp.data.select( \'core/editor\' ).getAutosaveAttribute( attributeName )`', {
- alternative: '`wp.data.select( \'core\' ).getAutosave( postType, postId, userId )`',
- plugin: 'Gutenberg',
- } );
-
- if ( ! includes( AUTOSAVE_PROPERTIES, attributeName ) ) {
+ if ( ! includes( AUTOSAVE_PROPERTIES, attributeName ) && attributeName !== 'preview_link' ) {
return;
}
@@ -392,15 +398,19 @@ export function isCurrentPostPending( state ) {
/**
* Return true if the current post has already been published.
*
- * @param {Object} state Global application state.
+ * @param {Object} state Global application state.
+ * @param {Object?} currentPost Explicit current post for bypassing registry selector.
*
* @return {boolean} Whether the post has been published.
*/
-export function isCurrentPostPublished( state ) {
- const post = getCurrentPost( state );
+export function isCurrentPostPublished( state, currentPost ) {
+ const post = currentPost || getCurrentPost( state );
- return [ 'publish', 'private' ].indexOf( post.status ) !== -1 ||
- ( post.status === 'future' && ! isInTheFuture( new Date( Number( getDate( post.date ) ) - ONE_MINUTE_IN_MS ) ) );
+ return (
+ [ 'publish', 'private' ].indexOf( post.status ) !== -1 ||
+ ( post.status === 'future' &&
+ ! isInTheFuture( new Date( Number( getDate( post.date ) ) - ONE_MINUTE_IN_MS ) ) )
+ );
}
/**
@@ -478,9 +488,9 @@ export function isEditedPostEmpty( state ) {
// condition of the mere existence of blocks. Note that the value of edited
// content takes precedent over block content, and must fall through to the
// default logic.
- const blocks = state.editor.present.blocks.value;
+ const blocks = getEditorBlocks( state );
- if ( blocks.length && ! ( 'content' in getPostEdits( state ) ) ) {
+ if ( blocks.length ) {
// Pierce the abstraction of the serializer in knowing that blocks are
// joined with with newlines such that even if every individual block
// produces an empty save result, the serialized content is non-empty.
@@ -654,9 +664,11 @@ export function isEditedPostDateFloating( state ) {
*
* @return {boolean} Whether post is being saved.
*/
-export function isSavingPost( state ) {
- return state.saving.requesting;
-}
+export const isSavingPost = createRegistrySelector( ( select ) => ( state ) => {
+ const postType = getCurrentPostType( state );
+ const postId = getCurrentPostId( state );
+ return select( 'core' ).isSavingEntityRecord( 'postType', postType, postId );
+} );
/**
* Returns true if a previous post save was attempted successfully, or false
@@ -666,9 +678,13 @@ export function isSavingPost( state ) {
*
* @return {boolean} Whether the post was saved successfully.
*/
-export function didPostSaveRequestSucceed( state ) {
- return state.saving.successful;
-}
+export const didPostSaveRequestSucceed = createRegistrySelector(
+ ( select ) => ( state ) => {
+ const postType = getCurrentPostType( state );
+ const postId = getCurrentPostId( state );
+ return ! select( 'core' ).getLastEntitySaveError( 'postType', postType, postId );
+ }
+);
/**
* Returns true if a previous post save was attempted but failed, or false
@@ -678,9 +694,13 @@ export function didPostSaveRequestSucceed( state ) {
*
* @return {boolean} Whether the post save failed.
*/
-export function didPostSaveRequestFail( state ) {
- return !! state.saving.error;
-}
+export const didPostSaveRequestFail = createRegistrySelector(
+ ( select ) => ( state ) => {
+ const postType = getCurrentPostType( state );
+ const postId = getCurrentPostId( state );
+ return !! select( 'core' ).getLastEntitySaveError( 'postType', postType, postId );
+ }
+);
/**
* Returns true if the post is autosaving, or false otherwise.
@@ -690,7 +710,10 @@ export function didPostSaveRequestFail( state ) {
* @return {boolean} Whether the post is autosaving.
*/
export function isAutosavingPost( state ) {
- return isSavingPost( state ) && !! state.saving.options.isAutosave;
+ if ( ! isSavingPost( state ) ) {
+ return false;
+ }
+ return !! get( state.saving, [ 'options', 'isAutosave' ] );
}
/**
@@ -701,7 +724,10 @@ export function isAutosavingPost( state ) {
* @return {boolean} Whether the post is being previewed.
*/
export function isPreviewingPost( state ) {
- return isSavingPost( state ) && !! state.saving.options.isPreview;
+ if ( ! isSavingPost( state ) ) {
+ return false;
+ }
+ return !! state.saving.options.isPreview;
}
/**
@@ -712,8 +738,19 @@ export function isPreviewingPost( state ) {
* @return {string?} Preview Link.
*/
export function getEditedPostPreviewLink( state ) {
+ if ( state.saving.pending || isSavingPost( state ) ) {
+ return;
+ }
+
+ let previewLink = getAutosaveAttribute( state, 'preview_link' );
+ if ( ! previewLink ) {
+ previewLink = getEditedPostAttribute( state, 'link' );
+ if ( previewLink ) {
+ previewLink = addQueryArgs( previewLink, { preview: true } );
+ }
+ }
const featuredImageId = getEditedPostAttribute( state, 'featured_media' );
- const previewLink = state.previewLink;
+
if ( previewLink && featuredImageId ) {
return addQueryArgs( previewLink, { _thumbnail_id: featuredImageId } );
}
@@ -731,7 +768,7 @@ export function getEditedPostPreviewLink( state ) {
* @return {?string} Suggested post format.
*/
export function getSuggestedPostFormat( state ) {
- const blocks = state.editor.present.blocks.value;
+ const blocks = getEditorBlocks( state );
let name;
// If there is only one block in the content of the post grab its name
@@ -774,11 +811,19 @@ export function getSuggestedPostFormat( state ) {
* Returns a set of blocks which are to be used in consideration of the post's
* generated save content.
*
+ * @deprecated since Gutenberg 6.2.0.
+ *
* @param {Object} state Editor state.
*
* @return {WPBlock[]} Filtered set of blocks for save.
*/
export function getBlocksForSerialization( state ) {
+ deprecated( '`core/editor` getBlocksForSerialization selector', {
+ plugin: 'Gutenberg',
+ alternative: 'getEditorBlocks',
+ hint: 'Blocks serialization pre-processing occurs at save time',
+ } );
+
const blocks = state.editor.present.blocks.value;
// WARNING: Any changes to the logic of this function should be verified
@@ -801,43 +846,31 @@ export function getBlocksForSerialization( state ) {
}
/**
- * Returns the content of the post being edited, preferring raw string edit
- * before falling back to serialization of block state.
+ * Returns the content of the post being edited.
*
* @param {Object} state Global application state.
*
* @return {string} Post content.
*/
-export const getEditedPostContent = createSelector(
- ( state ) => {
- const edits = getPostEdits( state );
- if ( 'content' in edits ) {
- return edits.content;
- }
-
- const blocks = getBlocksForSerialization( state );
- const content = serialize( blocks );
-
- // For compatibility purposes, treat a post consisting of a single
- // freeform block as legacy content and downgrade to a pre-block-editor
- // removep'd content format.
- const isSingleFreeformBlock = (
- blocks.length === 1 &&
- blocks[ 0 ].name === getFreeformContentHandlerName()
- );
-
- if ( isSingleFreeformBlock ) {
- return removep( content );
+export const getEditedPostContent = createRegistrySelector( ( select ) => ( state ) => {
+ const postId = getCurrentPostId( state );
+ const postType = getCurrentPostType( state );
+ const record = select( 'core' ).getEditedEntityRecord(
+ 'postType',
+ postType,
+ postId
+ );
+ if ( record ) {
+ if ( typeof record.content === 'function' ) {
+ return record.content( record );
+ } else if ( record.blocks ) {
+ return serializeBlocks( record.blocks );
+ } else if ( record.content ) {
+ return record.content;
}
-
- return content;
- },
- ( state ) => [
- state.editor.present.blocks.value,
- state.editor.present.edits.content,
- state.initialEdits.content,
- ],
-);
+ }
+ return '';
+} );
/**
* Returns the reusable block with the given ID.
@@ -956,7 +989,10 @@ export function isPublishingPost( state ) {
// Consider as publishing when current post prior to request was not
// considered published
- return !! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest );
+ return (
+ !! stateBeforeRequest &&
+ ! isCurrentPostPublished( null, stateBeforeRequest.currentPost )
+ );
}
/**
@@ -1130,7 +1166,7 @@ export function isPublishSidebarEnabled( state ) {
* @return {Array} Block list.
*/
export function getEditorBlocks( state ) {
- return state.editor.present.blocks.value;
+ return getEditedPostAttribute( state, 'blocks' ) || EMPTY_ARRAY;
}
/**
diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js
index ecb588c0912235..cdc354dca9346c 100644
--- a/packages/editor/src/store/test/actions.js
+++ b/packages/editor/src/store/test/actions.js
@@ -1,8 +1,3 @@
-/**
- * External dependencies
- */
-import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
-
/**
* WordPress dependencies
*/
@@ -14,7 +9,6 @@ import { select, dispatch, apiFetch } from '@wordpress/data-controls';
import * as actions from '../actions';
import {
STORE_KEY,
- SAVE_POST_NOTICE_ID,
TRASH_POST_NOTICE_ID,
POST_UPDATE_TRANSACTION_ID,
} from '../constants';
@@ -59,36 +53,14 @@ const postType = {
};
const postId = 44;
const postTypeSlug = 'post';
-const userId = 1;
describe( 'Post generator actions', () => {
describe( 'savePost()', () => {
let fulfillment,
- edits,
currentPost,
currentPostStatus,
- currentUser,
- editPostToSendOptimistic,
- autoSavePost,
- autoSavePostToSend,
- savedPost,
- savedPostStatus,
- isAutosave,
- isEditedPostNew,
- savedPostMessage;
+ isAutosave;
beforeEach( () => {
- edits = ( defaultStatus = null ) => {
- const postObject = {
- title: 'foo',
- content: 'bar',
- excerpt: 'cheese',
- foo: 'bar',
- };
- if ( defaultStatus !== null ) {
- postObject.status = defaultStatus;
- }
- return postObject;
- };
currentPost = () => ( {
id: postId,
type: postTypeSlug,
@@ -97,323 +69,167 @@ describe( 'Post generator actions', () => {
excerpt: 'crackers',
status: currentPostStatus,
} );
- currentUser = { id: userId };
- editPostToSendOptimistic = () => {
- const postObject = {
- ...edits(),
- content: editedPostContent,
- id: currentPost().id,
- };
- if ( ! postObject.status && isEditedPostNew ) {
- postObject.status = 'draft';
- }
- if ( isAutosave ) {
- delete postObject.foo;
- }
- return postObject;
- };
- autoSavePost = { status: 'autosave', bar: 'foo' };
- autoSavePostToSend = () => editPostToSendOptimistic();
- savedPost = () => (
- {
- ...currentPost(),
- ...editPostToSendOptimistic(),
- content: editedPostContent,
- status: savedPostStatus,
- }
- );
} );
- const editedPostContent = 'to infinity and beyond';
const reset = ( isAutosaving ) => fulfillment = actions.savePost(
{ isAutosave: isAutosaving }
);
- const rewind = ( isAutosaving, isNewPost ) => {
- reset( isAutosaving );
- fulfillment.next();
- fulfillment.next( true );
- fulfillment.next( edits() );
- fulfillment.next( isNewPost );
- fulfillment.next( currentPost() );
- fulfillment.next( editedPostContent );
- fulfillment.next( postTypeSlug );
- fulfillment.next( postType );
- fulfillment.next();
- if ( isAutosaving ) {
- fulfillment.next( currentUser );
- fulfillment.next();
- } else {
- fulfillment.next();
- fulfillment.next();
- }
- };
- const initialTestConditions = [
+ const testConditions = [
[
- 'yields action for selecting if edited post is saveable',
+ 'yields an action for checking if the post is saveable',
() => true,
() => {
reset( isAutosave );
const { value } = fulfillment.next();
- expect( value ).toEqual(
- select( STORE_KEY, 'isEditedPostSaveable' )
- );
+ expect( value ).toEqual( select( STORE_KEY, 'isEditedPostSaveable' ) );
},
],
[
- 'yields action for selecting the post edits done',
+ 'yields an action for selecting the current edited post content',
() => true,
() => {
const { value } = fulfillment.next( true );
- expect( value ).toEqual(
- select( STORE_KEY, 'getPostEdits' )
- );
- },
- ],
- [
- 'yields action for selecting whether the edited post is new',
- () => true,
- () => {
- const { value } = fulfillment.next( edits() );
- expect( value ).toEqual(
- select( STORE_KEY, 'isEditedPostNew' )
- );
+ expect( value ).toEqual( select( STORE_KEY, 'getEditedPostContent' ) );
},
],
[
- 'yields action for selecting the current post',
+ "yields an action for editing the post entity's content",
() => true,
() => {
- const { value } = fulfillment.next( isEditedPostNew );
- expect( value ).toEqual(
- select( STORE_KEY, 'getCurrentPost' )
- );
+ const edits = { content: currentPost().content };
+ const { value } = fulfillment.next( edits.content );
+ expect( value ).toEqual( dispatch( STORE_KEY, 'editPost', edits ) );
},
],
[
- 'yields action for selecting the edited post content',
+ 'yields an action for signalling that an update to the post started',
() => true,
() => {
- const { value } = fulfillment.next( currentPost() );
- expect( value ).toEqual(
- select( STORE_KEY, 'getEditedPostContent' )
- );
- },
- ],
- [
- 'yields action for selecting current post type slug',
- () => true,
- () => {
- const { value } = fulfillment.next( editedPostContent );
- expect( value ).toEqual(
- select( STORE_KEY, 'getCurrentPostType' )
- );
+ const { value } = fulfillment.next();
+ expect( value ).toEqual( {
+ type: 'REQUEST_POST_UPDATE_START',
+ options: { isAutosave },
+ } );
},
],
[
- 'yields action for selecting the post type object',
+ 'yields an action for selecting the current post',
() => true,
() => {
- const { value } = fulfillment.next( postTypeSlug );
- expect( value ).toEqual(
- select( 'core', 'getPostType', postTypeSlug )
- );
+ const { value } = fulfillment.next();
+ expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) );
},
],
[
- 'yields action for dispatching request post update start',
+ "yields an action for selecting the post entity's non transient edits",
() => true,
() => {
- const { value } = fulfillment.next( postType );
+ const post = currentPost();
+ const { value } = fulfillment.next( post );
expect( value ).toEqual(
- dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateStart',
- { isAutosave }
+ select(
+ 'core',
+ 'getEntityRecordNonTransientEdits',
+ 'postType',
+ post.type,
+ post.id
)
);
},
],
[
- 'yields action for dispatching optimistic update of post',
+ 'yields an action for dispatching an update to the post entity',
() => true,
() => {
- const { value } = fulfillment.next();
+ const post = currentPost();
+ const { value } = fulfillment.next( post );
expect( value ).toEqual(
dispatch(
- STORE_KEY,
- '__experimentalOptimisticUpdatePost',
- editPostToSendOptimistic()
- )
- );
- },
- ],
- [
- 'yields action for dispatching the removal of save post notice',
- ( isAutosaving ) => ! isAutosaving,
- () => {
- const { value } = fulfillment.next();
- expect( value ).toEqual(
- dispatch(
- 'core/notices',
- 'removeNotice',
- SAVE_POST_NOTICE_ID,
+ 'core',
+ 'saveEntityRecord',
+ 'postType',
+ post.type,
+ post,
+ {
+ isAutosave,
+ }
)
);
},
],
[
- 'yields action for dispatching the removal of autosave notice',
- ( isAutosaving ) => ! isAutosaving,
+ 'yields an action for signalling that an update to the post finished',
+ () => true,
() => {
const { value } = fulfillment.next();
- expect( value ).toEqual(
- dispatch(
- 'core/notices',
- 'removeNotice',
- 'autosave-exists'
- )
- );
+ expect( value ).toEqual( {
+ type: 'REQUEST_POST_UPDATE_FINISH',
+ options: { isAutosave },
+ } );
},
],
[
- 'yields action for selecting the currentUser',
- ( isAutosaving ) => isAutosaving,
+ "yields an action for selecting the entity's save error",
+ () => true,
() => {
+ const post = currentPost();
const { value } = fulfillment.next();
- expect( value ).toEqual(
- select( 'core', 'getCurrentUser' )
- );
- },
- ],
- [
- 'yields action for selecting the autosavePost',
- ( isAutosaving ) => isAutosaving,
- () => {
- const { value } = fulfillment.next( currentUser );
expect( value ).toEqual(
select(
'core',
- 'getAutosave',
- postTypeSlug,
- postId,
- userId
+ 'getLastEntitySaveError',
+ 'postType',
+ post.type,
+ post.id
)
);
},
],
- ];
- const fetchErrorConditions = [
[
- 'yields action for dispatching post update failure',
- () => {
- const error = { foo: 'bar', code: 'fail' };
- apiFetchThrowError( error );
- const editsObject = edits();
- const { value } = isAutosave ?
- fulfillment.next( autoSavePost ) :
- fulfillment.next();
- if ( isAutosave ) {
- delete editsObject.foo;
- }
- expect( value ).toEqual(
- dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateFailure',
- {
- post: currentPost(),
- edits: isEditedPostNew ?
- { ...editsObject, status: 'draft' } :
- editsObject,
- error,
- options: { isAutosave },
- }
- )
- );
- },
- ],
- [
- 'yields action for dispatching an appropriate error notice',
+ 'yields an action for selecting the current post',
+ () => true,
() => {
- const { value } = fulfillment.next( [ 'foo', 'bar' ] );
- expect( value ).toEqual(
- dispatch(
- 'core/notices',
- 'createErrorNotice',
- ...[ 'Updating failed.', { id: 'SAVE_POST_NOTICE_ID' } ]
- )
- );
+ const { value } = fulfillment.next();
+ expect( value ).toEqual( select( STORE_KEY, 'getCurrentPost' ) );
},
],
- ];
- const fetchSuccessConditions = [
[
- 'yields action for updating the post via the api',
+ 'yields an action for selecting the current post type config',
+ () => true,
() => {
- apiFetchDoActual();
- rewind( isAutosave, isEditedPostNew );
- const { value } = isAutosave ?
- fulfillment.next( autoSavePost ) :
- fulfillment.next();
- const data = isAutosave ?
- autoSavePostToSend() :
- editPostToSendOptimistic();
- const path = isAutosave ? '/autosaves' : '';
- expect( value ).toEqual(
- apiFetch(
- {
- path: `/wp/v2/${ postType.rest_base }/${ editPostToSendOptimistic().id }${ path }`,
- method: isAutosave ? 'POST' : 'PUT',
- data,
- }
- )
- );
+ const post = currentPost();
+ const { value } = fulfillment.next( post );
+ expect( value ).toEqual( select( 'core', 'getPostType', post.type ) );
},
],
[
- 'yields action for dispatch the appropriate reset action',
+ 'yields an action for dispatching a success notice',
+ () => true,
() => {
- const { value } = fulfillment.next( savedPost() );
-
- if ( isAutosave ) {
- expect( value ).toEqual( dispatch( 'core', 'receiveAutosaves', postId, savedPost() ) );
- } else {
- expect( value ).toEqual( dispatch( STORE_KEY, 'resetPost', savedPost() ) );
+ if ( ! isAutosave && currentPostStatus === 'publish' ) {
+ const { value } = fulfillment.next( postType );
+ expect( value ).toEqual(
+ dispatch(
+ 'core/notices',
+ 'createSuccessNotice',
+ 'Updated Post',
+ {
+ actions: [],
+ id: 'SAVE_POST_NOTICE_ID',
+ type: 'snackbar',
+ }
+ )
+ );
}
},
],
[
- 'yields action for dispatching the post update success',
- () => {
- const { value } = fulfillment.next();
- expect( value ).toEqual(
- dispatch(
- STORE_KEY,
- '__experimentalRequestPostUpdateSuccess',
- {
- previousPost: currentPost(),
- post: savedPost(),
- options: { isAutosave },
- postType,
- isRevision: false,
- }
- )
- );
- },
- ],
- [
- 'yields dispatch action for success notification',
+ 'implicitly returns undefined',
+ () => true,
() => {
- const { value } = fulfillment.next( [ 'foo', 'bar' ] );
- const expected = isAutosave ?
- undefined :
- dispatch(
- 'core/notices',
- 'createSuccessNotice',
- ...[
- savedPostMessage,
- { actions: [], id: 'SAVE_POST_NOTICE_ID', type: 'snackbar' },
- ]
- );
- expect( value ).toEqual( expected );
+ expect( fulfillment.next() ).toEqual( {
+ done: true,
+ value: undefined,
+ } );
},
],
];
@@ -430,74 +246,27 @@ describe( 'Post generator actions', () => {
}
};
- const testRunRoutine = ( [ testDescription, testRoutine ] ) => {
- it( testDescription, () => {
- testRoutine();
- } );
- };
-
- describe( 'yields with expected responses when edited post is not saveable', () => {
- it( 'yields action for selecting if edited post is saveable', () => {
- reset( false );
- const { value } = fulfillment.next();
- expect( value ).toEqual(
- select( STORE_KEY, 'isEditedPostSaveable' )
- );
- } );
- it( 'if edited post is not saveable then bails', () => {
- const { value, done } = fulfillment.next( false );
- expect( done ).toBe( true );
- expect( value ).toBeUndefined();
- } );
- } );
describe( 'yields with expected responses for when not autosaving and edited post is new', () => {
beforeEach( () => {
isAutosave = false;
- isEditedPostNew = true;
- savedPostStatus = 'publish';
currentPostStatus = 'draft';
- savedPostMessage = 'Post published';
- } );
- initialTestConditions.forEach( conditionalRunTestRoutine( false ) );
- describe( 'fetch action throwing an error', () => {
- fetchErrorConditions.forEach( testRunRoutine );
- } );
- describe( 'fetch action not throwing an error', () => {
- fetchSuccessConditions.forEach( testRunRoutine );
} );
+ testConditions.forEach( conditionalRunTestRoutine( false ) );
} );
describe( 'yields with expected responses for when not autosaving and edited post is not new', () => {
beforeEach( () => {
isAutosave = false;
- isEditedPostNew = false;
currentPostStatus = 'publish';
- savedPostStatus = 'publish';
- savedPostMessage = 'Updated Post';
- } );
- initialTestConditions.forEach( conditionalRunTestRoutine( false ) );
- describe( 'fetch action throwing error', () => {
- fetchErrorConditions.forEach( testRunRoutine );
- } );
- describe( 'fetch action not throwing error', () => {
- fetchSuccessConditions.forEach( testRunRoutine );
} );
+ testConditions.forEach( conditionalRunTestRoutine( false ) );
} );
describe( 'yields with expected responses for when autosaving is true and edited post is not new', () => {
beforeEach( () => {
isAutosave = true;
- isEditedPostNew = false;
currentPostStatus = 'autosave';
- savedPostStatus = 'publish';
- savedPostMessage = 'Post published';
- } );
- initialTestConditions.forEach( conditionalRunTestRoutine( true ) );
- describe( 'fetch action throwing error', () => {
- fetchErrorConditions.forEach( testRunRoutine );
- } );
- describe( 'fetch action not throwing error', () => {
- fetchSuccessConditions.forEach( testRunRoutine );
} );
+ testConditions.forEach( conditionalRunTestRoutine( true ) );
} );
} );
describe( 'autosave()', () => {
@@ -663,14 +432,15 @@ describe( 'Editor actions', () => {
} );
it( 'should yield action object for resetEditorBlocks', () => {
const { value } = fulfillment.next();
- expect( value ).toEqual( actions.resetEditorBlocks( [] ) );
+ expect( Object.keys( value ) ).toEqual( [] );
} );
it( 'should yield action object for setupEditorState', () => {
const { value } = fulfillment.next();
expect( value ).toEqual(
- actions.setupEditorState(
- { content: { raw: '' }, status: 'publish' }
- )
+ actions.setupEditorState( {
+ content: { raw: '' },
+ status: 'publish',
+ } )
);
} );
} );
@@ -691,51 +461,11 @@ describe( 'Editor actions', () => {
const result = actions.__experimentalRequestPostUpdateStart();
expect( result ).toEqual( {
type: 'REQUEST_POST_UPDATE_START',
- optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID },
options: {},
} );
} );
} );
- describe( 'requestPostUpdateSuccess', () => {
- it( 'should return the REQUEST_POST_UPDATE_SUCCESS action', () => {
- const testActionData = {
- previousPost: {},
- post: {},
- options: {},
- postType: 'post',
- };
- const result = actions.__experimentalRequestPostUpdateSuccess( {
- ...testActionData,
- isRevision: false,
- } );
- expect( result ).toEqual( {
- ...testActionData,
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID },
- } );
- } );
- } );
-
- describe( 'requestPostUpdateFailure', () => {
- it( 'should return the REQUEST_POST_UPDATE_FAILURE action', () => {
- const testActionData = {
- post: {},
- options: {},
- edits: {},
- error: {},
- };
- const result = actions.__experimentalRequestPostUpdateFailure(
- testActionData
- );
- expect( result ).toEqual( {
- ...testActionData,
- type: 'REQUEST_POST_UPDATE_FAILURE',
- optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID },
- } );
- } );
- } );
-
describe( 'updatePost', () => {
it( 'should return the UPDATE_POST action', () => {
const edits = {};
@@ -748,11 +478,28 @@ describe( 'Editor actions', () => {
} );
describe( 'editPost', () => {
- it( 'should return EDIT_POST action', () => {
+ it( 'should edit the relevant entity record', () => {
const edits = { format: 'sample' };
- expect( actions.editPost( edits ) ).toEqual( {
- type: 'EDIT_POST',
- edits,
+ const fulfillment = actions.editPost( edits );
+ expect( fulfillment.next() ).toEqual( {
+ done: false,
+ value: select( STORE_KEY, 'getCurrentPost' ),
+ } );
+ const post = { id: 1, type: 'post' };
+ expect( fulfillment.next( post ) ).toEqual( {
+ done: false,
+ value: dispatch(
+ 'core',
+ 'editEntityRecord',
+ 'postType',
+ post.type,
+ post.id,
+ edits
+ ),
+ } );
+ expect( fulfillment.next() ).toEqual( {
+ done: true,
+ value: undefined,
} );
} );
} );
@@ -770,17 +517,29 @@ describe( 'Editor actions', () => {
} );
describe( 'redo', () => {
- it( 'should return REDO action', () => {
- expect( actions.redo() ).toEqual( {
- type: 'REDO',
+ it( 'should yield the REDO action', () => {
+ const fulfillment = actions.redo();
+ expect( fulfillment.next() ).toEqual( {
+ done: false,
+ value: dispatch( 'core', 'redo' ),
+ } );
+ expect( fulfillment.next() ).toEqual( {
+ done: true,
+ value: undefined,
} );
} );
} );
describe( 'undo', () => {
- it( 'should return UNDO action', () => {
- expect( actions.undo() ).toEqual( {
- type: 'UNDO',
+ it( 'should yield the UNDO action', () => {
+ const fulfillment = actions.undo();
+ expect( fulfillment.next() ).toEqual( {
+ done: false,
+ value: dispatch( 'core', 'undo' ),
+ } );
+ expect( fulfillment.next() ).toEqual( {
+ done: true,
+ value: undefined,
} );
} );
} );
diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js
index d964c72c0f77a3..3fc6461b34e538 100644
--- a/packages/editor/src/store/test/reducer.js
+++ b/packages/editor/src/store/test/reducer.js
@@ -11,16 +11,11 @@ import {
isUpdatingSamePostProperty,
shouldOverwriteState,
getPostRawValue,
- initialEdits,
- editor,
- currentPost,
preferences,
saving,
reusableBlocks,
postSavingLock,
- previewLink,
} from '../reducer';
-import { INITIAL_EDITS_DEFAULTS } from '../defaults';
describe( 'state', () => {
describe( 'hasSameKeys()', () => {
@@ -156,326 +151,6 @@ describe( 'state', () => {
} );
} );
- describe( 'editor()', () => {
- describe( 'blocks()', () => {
- it( 'should set its value by RESET_EDITOR_BLOCKS', () => {
- const blocks = [ {
- clientId: 'block3',
- innerBlocks: [
- { clientId: 'block31', innerBlocks: [] },
- { clientId: 'block32', innerBlocks: [] },
- ],
- } ];
- const state = editor( undefined, {
- type: 'RESET_EDITOR_BLOCKS',
- blocks,
- } );
-
- expect( state.present.blocks.value ).toBe( blocks );
- } );
- } );
-
- describe( 'edits()', () => {
- it( 'should save newly edited properties', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- status: 'draft',
- title: 'post title',
- },
- } );
-
- const state = editor( original, {
- type: 'EDIT_POST',
- edits: {
- tags: [ 1 ],
- },
- } );
-
- expect( state.present.edits ).toEqual( {
- status: 'draft',
- title: 'post title',
- tags: [ 1 ],
- } );
- } );
-
- it( 'should return same reference if no changed properties', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- status: 'draft',
- title: 'post title',
- },
- } );
-
- const state = editor( original, {
- type: 'EDIT_POST',
- edits: {
- status: 'draft',
- },
- } );
-
- expect( state.present.edits ).toBe( original.present.edits );
- } );
-
- it( 'should save modified properties', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- status: 'draft',
- title: 'post title',
- tags: [ 1 ],
- },
- } );
-
- const state = editor( original, {
- type: 'EDIT_POST',
- edits: {
- title: 'modified title',
- tags: [ 2 ],
- },
- } );
-
- expect( state.present.edits ).toEqual( {
- status: 'draft',
- title: 'modified title',
- tags: [ 2 ],
- } );
- } );
-
- it( 'should merge object values', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- meta: {
- a: 1,
- },
- },
- } );
-
- const state = editor( original, {
- type: 'EDIT_POST',
- edits: {
- meta: {
- b: 2,
- },
- },
- } );
-
- expect( state.present.edits ).toEqual( {
- meta: {
- a: 1,
- b: 2,
- },
- } );
- } );
-
- it( 'return state by reference on unchanging update', () => {
- const original = editor( undefined, {} );
-
- const state = editor( original, {
- type: 'UPDATE_POST',
- edits: {},
- } );
-
- expect( state.present.edits ).toBe( original.present.edits );
- } );
-
- it( 'unset reset post values which match by canonical value', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- title: 'modified title',
- },
- } );
-
- const state = editor( original, {
- type: 'RESET_POST',
- post: {
- title: {
- raw: 'modified title',
- },
- },
- } );
-
- expect( state.present.edits ).toEqual( {} );
- } );
-
- it( 'unset reset post values by deep match', () => {
- const original = editor( undefined, {
- type: 'EDIT_POST',
- edits: {
- title: 'modified title',
- meta: {
- a: 1,
- b: 2,
- },
- },
- } );
-
- const state = editor( original, {
- type: 'UPDATE_POST',
- edits: {
- title: 'modified title',
- meta: {
- a: 1,
- b: 2,
- },
- },
- } );
-
- expect( state.present.edits ).toEqual( {} );
- } );
-
- it( 'should omit content when resetting', () => {
- // Use case: When editing in Text mode, we defer to content on
- // the property, but we reset blocks by parse when switching
- // back to Visual mode.
- const original = deepFreeze( editor( undefined, {} ) );
- let state = editor( original, {
- type: 'EDIT_POST',
- edits: {
- content: 'bananas',
- },
- } );
-
- expect( state.present.edits ).toHaveProperty( 'content' );
-
- state = editor( original, {
- type: 'RESET_EDITOR_BLOCKS',
- blocks: [ {
- clientId: 'kumquat',
- name: 'core/test-block',
- attributes: {},
- innerBlocks: [],
- }, {
- clientId: 'loquat',
- name: 'core/test-block',
- attributes: {},
- innerBlocks: [],
- } ],
- } );
-
- expect( state.present.edits ).not.toHaveProperty( 'content' );
- } );
- } );
- } );
-
- describe( 'initialEdits', () => {
- it( 'should default to initial edits', () => {
- const state = initialEdits( undefined, {} );
-
- expect( state ).toBe( INITIAL_EDITS_DEFAULTS );
- } );
-
- it( 'should return initial edits on post reset', () => {
- const state = initialEdits( undefined, {
- type: 'RESET_POST',
- } );
-
- expect( state ).toBe( INITIAL_EDITS_DEFAULTS );
- } );
-
- it( 'should return referentially equal state if setup includes no edits', () => {
- const original = initialEdits( undefined, {} );
- const state = initialEdits( deepFreeze( original ), {
- type: 'SETUP_EDITOR',
- } );
-
- expect( state ).toBe( original );
- } );
-
- it( 'should return referentially equal state if reset while having made no edits', () => {
- const original = initialEdits( undefined, {} );
- const state = initialEdits( deepFreeze( original ), {
- type: 'RESET_POST',
- } );
-
- expect( state ).toBe( original );
- } );
-
- it( 'should return setup edits', () => {
- const original = initialEdits( undefined, {} );
- const state = initialEdits( deepFreeze( original ), {
- type: 'SETUP_EDITOR',
- edits: {
- title: '',
- content: '',
- },
- } );
-
- expect( state ).toEqual( {
- title: '',
- content: '',
- } );
- } );
-
- it( 'should unset content on editor setup', () => {
- const original = initialEdits( undefined, {
- type: 'SETUP_EDITOR',
- edits: {
- title: '',
- content: '',
- },
- } );
- const state = initialEdits( deepFreeze( original ), {
- type: 'SETUP_EDITOR_STATE',
- } );
-
- expect( state ).toEqual( { title: '' } );
- } );
-
- it( 'should unset values on post update', () => {
- const original = initialEdits( undefined, {
- type: 'SETUP_EDITOR',
- edits: {
- title: '',
- },
- } );
- const state = initialEdits( deepFreeze( original ), {
- type: 'UPDATE_POST',
- edits: {
- title: '',
- },
- } );
-
- expect( state ).toEqual( {} );
- } );
- } );
-
- describe( 'currentPost()', () => {
- it( 'should reset a post object', () => {
- const original = deepFreeze( { title: 'unmodified' } );
-
- const state = currentPost( original, {
- type: 'RESET_POST',
- post: {
- title: 'new post',
- },
- } );
-
- expect( state ).toEqual( {
- title: 'new post',
- } );
- } );
-
- it( 'should update the post object with UPDATE_POST', () => {
- const original = deepFreeze( { title: 'unmodified', status: 'publish' } );
-
- const state = currentPost( original, {
- type: 'UPDATE_POST',
- edits: {
- title: 'updated post object from server',
- },
- } );
-
- expect( state ).toEqual( {
- title: 'updated post object from server',
- status: 'publish',
- } );
- } );
- } );
-
describe( 'preferences()', () => {
it( 'should apply all defaults', () => {
const state = preferences( undefined, {} );
@@ -508,43 +183,11 @@ describe( 'state', () => {
it( 'should update when a request is started', () => {
const state = saving( null, {
type: 'REQUEST_POST_UPDATE_START',
+ options: { isAutosave: true },
} );
expect( state ).toEqual( {
- requesting: true,
- successful: false,
- error: null,
- options: {},
- } );
- } );
-
- it( 'should update when a request succeeds', () => {
- const state = saving( null, {
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- } );
- expect( state ).toEqual( {
- requesting: false,
- successful: true,
- error: null,
- options: {},
- } );
- } );
-
- it( 'should update when a request fails', () => {
- const state = saving( null, {
- type: 'REQUEST_POST_UPDATE_FAILURE',
- error: {
- code: 'pretend_error',
- message: 'update failed',
- },
- } );
- expect( state ).toEqual( {
- requesting: false,
- successful: false,
- error: {
- code: 'pretend_error',
- message: 'update failed',
- },
- options: {},
+ pending: true,
+ options: { isAutosave: true },
} );
} );
} );
@@ -846,69 +489,4 @@ describe( 'state', () => {
expect( state ).toEqual( {} );
} );
} );
-
- describe( 'previewLink', () => {
- it( 'returns null by default', () => {
- const state = previewLink( undefined, {} );
-
- expect( state ).toBe( null );
- } );
-
- it( 'returns preview link from save success', () => {
- const state = previewLink( null, {
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- post: {
- preview_link: 'https://example.com/?p=2611&preview=true',
- },
- } );
-
- expect( state ).toBe( 'https://example.com/?p=2611&preview=true' );
- } );
-
- it( 'returns post link with query arg from save success if no preview link', () => {
- const state = previewLink( null, {
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- post: {
- link: 'https://example.com/sample-post/',
- },
- } );
-
- expect( state ).toBe( 'https://example.com/sample-post/?preview=true' );
- } );
-
- it( 'returns same state if save success without preview link or post link', () => {
- // Bug: This can occur for post types which are defined as
- // `publicly_queryable => false` (non-viewable).
- //
- // See: https://github.com/WordPress/gutenberg/issues/12677
- const state = previewLink( null, {
- type: 'REQUEST_POST_UPDATE_SUCCESS',
- post: {
- preview_link: '',
- },
- } );
-
- expect( state ).toBe( null );
- } );
-
- it( 'returns resets on preview start', () => {
- const state = previewLink( 'https://example.com/sample-post/', {
- type: 'REQUEST_POST_UPDATE_START',
- options: {
- isPreview: true,
- },
- } );
-
- expect( state ).toBe( null );
- } );
-
- it( 'returns state on non-preview save start', () => {
- const state = previewLink( 'https://example.com/sample-post/', {
- type: 'REQUEST_POST_UPDATE_START',
- options: {},
- } );
-
- expect( state ).toBe( 'https://example.com/sample-post/' );
- } );
- } );
} );
diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js
index 6d10b7f863aead..73e0938ffccce4 100644
--- a/packages/editor/src/store/test/selectors.js
+++ b/packages/editor/src/store/test/selectors.js
@@ -20,10 +20,103 @@ import { RawHTML } from '@wordpress/element';
/**
* Internal dependencies
*/
-import * as selectors from '../selectors';
+import * as _selectors from '../selectors';
import { PREFERENCES_DEFAULTS } from '../defaults';
import { POST_UPDATE_TRANSACTION_ID } from '../constants';
+const selectors = { ..._selectors };
+const selectorNames = Object.keys( selectors );
+selectorNames.forEach( ( name ) => {
+ selectors[ name ] = ( state, ...args ) => {
+ const select = () => ( {
+ getRawEntityRecord() {
+ return state.currentPost;
+ },
+
+ getEntityRecordEdits() {
+ const present = state.editor && state.editor.present;
+ let edits = present && present.edits;
+
+ if ( state.initialEdits ) {
+ edits = {
+ ...state.initialEdits,
+ ...edits,
+ };
+ }
+
+ const { value: blocks, isDirty } = ( present && present.blocks ) || {};
+ if ( blocks && isDirty !== false ) {
+ edits = {
+ ...edits,
+ blocks,
+ };
+ }
+
+ return edits;
+ },
+
+ hasEditsForEntityRecord() {
+ return Object.keys( this.getEntityRecordEdits() ).length > 0;
+ },
+
+ getEditedEntityRecord() {
+ return {
+ ...this.getRawEntityRecord(),
+ ...this.getEntityRecordEdits(),
+ };
+ },
+
+ isSavingEntityRecord() {
+ return state.saving && state.saving.requesting;
+ },
+
+ getLastEntitySaveError() {
+ const saving = state.saving;
+ const successful = saving && saving.successful;
+ const error = saving && saving.error;
+ return successful === undefined ? error : ! successful;
+ },
+
+ hasUndo() {
+ return Boolean(
+ state.editor && state.editor.past && state.editor.past.length
+ );
+ },
+
+ hasRedo() {
+ return Boolean(
+ state.editor && state.editor.future && state.editor.future.length
+ );
+ },
+
+ getCurrentUser() {
+ return state.getCurrentUser && state.getCurrentUser();
+ },
+
+ hasFetchedAutosaves() {
+ return state.hasFetchedAutosaves && state.hasFetchedAutosaves();
+ },
+
+ getAutosave() {
+ return state.getAutosave && state.getAutosave();
+ },
+ } );
+
+ selectorNames.forEach( ( otherName ) => {
+ if ( _selectors[ otherName ].isRegistrySelector ) {
+ _selectors[ otherName ].registry = { select };
+ }
+ } );
+
+ return _selectors[ name ]( state, ...args );
+ };
+ selectors[ name ].isRegistrySelector = _selectors[ name ].isRegistrySelector;
+ if ( selectors[ name ].isRegistrySelector ) {
+ selectors[ name ].registry = {
+ select: () => _selectors[ name ].registry.select(),
+ };
+ }
+} );
const {
hasEditorUndo,
hasEditorRedo,
@@ -44,7 +137,7 @@ const {
isCurrentPostScheduled,
isEditedPostPublishable,
isEditedPostSaveable,
- isEditedPostAutosaveable: _isEditedPostAutosaveableRegistrySelector,
+ isEditedPostAutosaveable,
isEditedPostEmpty,
isEditedPostBeingScheduled,
isEditedPostDateFloating,
@@ -71,14 +164,9 @@ const {
describe( 'selectors', () => {
let cachedSelectors;
- let isEditedPostAutosaveableRegistrySelector;
beforeAll( () => {
cachedSelectors = filter( selectors, ( selector ) => selector.clear );
- isEditedPostAutosaveableRegistrySelector = ( select ) => {
- _isEditedPostAutosaveableRegistrySelector.registry = { select };
- return _isEditedPostAutosaveableRegistrySelector;
- };
} );
beforeEach( () => {
@@ -354,37 +442,6 @@ describe( 'selectors', () => {
expect( isEditedPostDirty( state ) ).toBe( true );
} );
-
- it( 'should return true if pending transaction with dirty state', () => {
- const state = {
- optimist: [
- {
- beforeState: {
- editor: {
- present: {
- blocks: {
- isDirty: true,
- value: [],
- },
- edits: {},
- },
- },
- },
- },
- ],
- editor: {
- present: {
- blocks: {
- isDirty: false,
- value: [],
- },
- edits: {},
- },
- },
- };
-
- expect( isEditedPostDirty( state ) ).toBe( true );
- } );
} );
describe( 'isCleanNewPost', () => {
@@ -471,7 +528,7 @@ describe( 'selectors', () => {
describe( 'getCurrentPostId', () => {
it( 'should return null if the post has not yet been saved', () => {
const state = {
- currentPost: {},
+ postId: null,
};
expect( getCurrentPostId( state ) ).toBeNull();
@@ -479,7 +536,7 @@ describe( 'selectors', () => {
it( 'should return the current post ID', () => {
const state = {
- currentPost: { id: 1 },
+ postId: 1,
};
expect( getCurrentPostId( state ) ).toBe( 1 );
@@ -673,9 +730,7 @@ describe( 'selectors', () => {
describe( 'getCurrentPostType', () => {
it( 'should return the post type', () => {
const state = {
- currentPost: {
- type: 'post',
- },
+ postType: 'post',
};
expect( getCurrentPostType( state ) ).toBe( 'post' );
@@ -1272,18 +1327,6 @@ describe( 'selectors', () => {
describe( 'isEditedPostAutosaveable', () => {
it( 'should return false if existing autosaves have not yet been fetched', () => {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
- getCurrentUser() {},
- hasFetchedAutosaves() {
- return false;
- },
- getAutosave() {
- return {
- title: 'sassel',
- };
- },
- } ) );
-
const state = {
editor: {
present: {
@@ -1300,24 +1343,21 @@ describe( 'selectors', () => {
saving: {
requesting: true,
},
- };
-
- expect( isEditedPostAutosaveable( state ) ).toBe( false );
- } );
-
- it( 'should return false if the post is not saveable', () => {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
getCurrentUser() {},
hasFetchedAutosaves() {
- return true;
+ return false;
},
getAutosave() {
return {
title: 'sassel',
};
},
- } ) );
+ };
+ expect( isEditedPostAutosaveable( state ) ).toBe( false );
+ } );
+
+ it( 'should return false if the post is not saveable', () => {
const state = {
editor: {
present: {
@@ -1334,20 +1374,21 @@ describe( 'selectors', () => {
saving: {
requesting: true,
},
+ getCurrentUser() {},
+ hasFetchedAutosaves() {
+ return true;
+ },
+ getAutosave() {
+ return {
+ title: 'sassel',
+ };
+ },
};
expect( isEditedPostAutosaveable( state ) ).toBe( false );
} );
it( 'should return true if there is no autosave', () => {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
- getCurrentUser() {},
- hasFetchedAutosaves() {
- return true;
- },
- getAutosave() {},
- } ) );
-
const state = {
editor: {
present: {
@@ -1362,25 +1403,17 @@ describe( 'selectors', () => {
title: 'sassel',
},
saving: {},
+ getCurrentUser() {},
+ hasFetchedAutosaves() {
+ return true;
+ },
+ getAutosave() {},
};
expect( isEditedPostAutosaveable( state ) ).toBe( true );
} );
it( 'should return false if none of title, excerpt, or content have changed', () => {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
- getCurrentUser() {},
- hasFetchedAutosaves() {
- return true;
- },
- getAutosave() {
- return {
- title: 'foo',
- excerpt: 'foo',
- };
- },
- } ) );
-
const state = {
editor: {
present: {
@@ -1397,13 +1430,6 @@ describe( 'selectors', () => {
excerpt: 'foo',
},
saving: {},
- };
-
- expect( isEditedPostAutosaveable( state ) ).toBe( false );
- } );
-
- it( 'should return true if content has changes', () => {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
getCurrentUser() {},
hasFetchedAutosaves() {
return true;
@@ -1414,8 +1440,12 @@ describe( 'selectors', () => {
excerpt: 'foo',
};
},
- } ) );
+ };
+
+ expect( isEditedPostAutosaveable( state ) ).toBe( false );
+ } );
+ it( 'should return true if content has changes', () => {
const state = {
editor: {
present: {
@@ -1431,6 +1461,16 @@ describe( 'selectors', () => {
excerpt: 'foo',
},
saving: {},
+ getCurrentUser() {},
+ hasFetchedAutosaves() {
+ return true;
+ },
+ getAutosave() {
+ return {
+ title: 'foo',
+ excerpt: 'foo',
+ };
+ },
};
expect( isEditedPostAutosaveable( state ) ).toBe( true );
@@ -1439,19 +1479,6 @@ describe( 'selectors', () => {
it( 'should return true if title or excerpt have changed', () => {
for ( const variantField of [ 'title', 'excerpt' ] ) {
for ( const constantField of without( [ 'title', 'excerpt' ], variantField ) ) {
- const isEditedPostAutosaveable = isEditedPostAutosaveableRegistrySelector( () => ( {
- getCurrentUser() {},
- hasFetchedAutosaves() {
- return true;
- },
- getAutosave() {
- return {
- [ constantField ]: 'foo',
- [ variantField ]: 'bar',
- };
- },
- } ) );
-
const state = {
editor: {
present: {
@@ -1468,6 +1495,16 @@ describe( 'selectors', () => {
content: 'foo',
},
saving: {},
+ getCurrentUser() {},
+ hasFetchedAutosaves() {
+ return true;
+ },
+ getAutosave() {
+ return {
+ [ constantField ]: 'foo',
+ [ variantField ]: 'bar',
+ };
+ },
};
expect( isEditedPostAutosaveable( state ) ).toBe( true );
@@ -1546,7 +1583,7 @@ describe( 'selectors', () => {
expect( isEditedPostEmpty( state ) ).toBe( true );
} );
- it( 'should return true if blocks, but empty content edit', () => {
+ it( 'should return false if blocks, but empty content edit', () => {
const state = {
editor: {
present: {
@@ -1571,7 +1608,7 @@ describe( 'selectors', () => {
},
};
- expect( isEditedPostEmpty( state ) ).toBe( true );
+ expect( isEditedPostEmpty( state ) ).toBe( false );
} );
it( 'should return true if the post has an empty content property', () => {
@@ -1593,7 +1630,7 @@ describe( 'selectors', () => {
expect( isEditedPostEmpty( state ) ).toBe( true );
} );
- it( 'should return false if edits include a non-empty content property', () => {
+ it( 'should return true if edits include a non-empty content property, but blocks are empty', () => {
const state = {
editor: {
present: {
@@ -1609,7 +1646,7 @@ describe( 'selectors', () => {
currentPost: {},
};
- expect( isEditedPostEmpty( state ) ).toBe( false );
+ expect( isEditedPostEmpty( state ) ).toBe( true );
} );
it( 'should return true if empty classic block', () => {
@@ -2102,7 +2139,7 @@ describe( 'selectors', () => {
} );
} );
- it( 'defers to returning an edited post attribute', () => {
+ it( 'serializes blocks, if any', () => {
const block = createBlock( 'core/block' );
const state = {
@@ -2122,7 +2159,7 @@ describe( 'selectors', () => {
const content = getEditedPostContent( state );
- expect( content ).toBe( 'custom edit' );
+ expect( content ).toBe( '' );
} );
it( 'returns serialization of blocks', () => {
diff --git a/packages/editor/src/store/utils/serialize-blocks.js b/packages/editor/src/store/utils/serialize-blocks.js
new file mode 100644
index 00000000000000..7301350399ca50
--- /dev/null
+++ b/packages/editor/src/store/utils/serialize-blocks.js
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import memoize from 'memize';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ isUnmodifiedDefaultBlock,
+ serialize,
+ getFreeformContentHandlerName,
+} from '@wordpress/blocks';
+import { removep } from '@wordpress/autop';
+
+/**
+ * Serializes blocks following backwards compatibility conventions.
+ *
+ * @param {Array} blocksForSerialization The blocks to serialize.
+ *
+ * @return {string} The blocks serialization.
+ */
+const serializeBlocks = memoize(
+ ( blocksForSerialization ) => {
+ // A single unmodified default block is assumed to
+ // be equivalent to an empty post.
+ if (
+ blocksForSerialization.length === 1 &&
+ isUnmodifiedDefaultBlock( blocksForSerialization[ 0 ] )
+ ) {
+ blocksForSerialization = [];
+ }
+
+ let content = serialize( blocksForSerialization );
+
+ // For compatibility, treat a post consisting of a
+ // single freeform block as legacy content and apply
+ // pre-block-editor removep'd content formatting.
+ if (
+ blocksForSerialization.length === 1 &&
+ blocksForSerialization[ 0 ].name === getFreeformContentHandlerName()
+ ) {
+ content = removep( content );
+ }
+
+ return content;
+ },
+ { maxSize: 1 }
+);
+
+export default serializeBlocks;
diff --git a/packages/editor/src/utils/index.js b/packages/editor/src/utils/index.js
index 0f246c16e4aec8..d1105bd71f6dd9 100644
--- a/packages/editor/src/utils/index.js
+++ b/packages/editor/src/utils/index.js
@@ -5,3 +5,5 @@ import mediaUpload from './media-upload';
export { mediaUpload };
export { cleanForSlug } from './url.js';
+
+export { default as serializeBlocks } from '../store/utils/serialize-blocks';
diff --git a/packages/editor/src/utils/with-change-detection/README.md b/packages/editor/src/utils/with-change-detection/README.md
deleted file mode 100644
index 75f6061483f832..00000000000000
--- a/packages/editor/src/utils/with-change-detection/README.md
+++ /dev/null
@@ -1,41 +0,0 @@
-withChangeDetection
-===================
-
-`withChangeDetection` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking changes to reducer state over time.
-
-It does this based on the following assumptions:
-
-- The original reducer returns an object
-- The original reducer returns a new reference only if a change has in-fact occurred
-
-Using these assumptions, the enhanced reducer returned from `withChangeDetection` will include a new property on the object `isDirty` corresponding to whether the original reference of the reducer has ever changed.
-
-Leveraging a `resetTypes` option, this can be used to mark intervals at which a state is considered to be clean (without changes) and dirty (with changes).
-
-## Example
-
-Considering a simple count reducer, we can enhance it with `withChangeDetection` to reflect whether changes have occurred:
-
-```js
-function counter( state = { count: 0 }, action ) {
- switch ( action.type ) {
- case 'INCREMENT':
- return { ...state, count: state.count + 1 };
- }
-
- return state;
-}
-
-const enhancedCounter = withChangeDetection( counter, { resetTypes: [ 'RESET' ] } );
-
-let state;
-
-state = enhancedCounter( undefined, {} );
-// { count: 0, isDirty: false }
-
-state = enhancedCounter( state, { type: 'INCREMENT' } );
-// { count: 1, isDirty: true }
-
-state = enhancedCounter( state, { type: 'RESET' } );
-// { count: 1, isDirty: false }
-```
diff --git a/packages/editor/src/utils/with-change-detection/index.js b/packages/editor/src/utils/with-change-detection/index.js
deleted file mode 100644
index e86db9a9905d4b..00000000000000
--- a/packages/editor/src/utils/with-change-detection/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * External dependencies
- */
-import { includes } from 'lodash';
-
-/**
- * Higher-order reducer creator for tracking changes to state over time. The
- * returned reducer will include a `isDirty` property on the object reflecting
- * whether the original reference of the reducer has changed.
- *
- * @param {?Object} options Optional options.
- * @param {?Array} options.ignoreTypes Action types upon which to skip check.
- * @param {?Array} options.resetTypes Action types upon which to reset dirty.
- *
- * @return {Function} Higher-order reducer.
- */
-const withChangeDetection = ( options = {} ) => ( reducer ) => {
- return ( state, action ) => {
- let nextState = reducer( state, action );
-
- // Reset at:
- // - Initial state
- // - Reset types
- const isReset = (
- state === undefined ||
- includes( options.resetTypes, action.type )
- );
-
- const isChanging = state !== nextState;
-
- // If not intending to update dirty flag, return early and avoid clone.
- if ( ! isChanging && ! isReset ) {
- return state;
- }
-
- // Avoid mutating state, unless it's already changing by original
- // reducer and not initial.
- if ( ! isChanging || state === undefined ) {
- nextState = { ...nextState };
- }
-
- const isIgnored = includes( options.ignoreTypes, action.type );
-
- if ( isIgnored ) {
- // Preserve the original value if ignored.
- nextState.isDirty = state.isDirty;
- } else {
- nextState.isDirty = ! isReset && isChanging;
- }
-
- return nextState;
- };
-};
-
-export default withChangeDetection;
diff --git a/packages/editor/src/utils/with-change-detection/test/index.js b/packages/editor/src/utils/with-change-detection/test/index.js
deleted file mode 100644
index 06abccb50fc5b6..00000000000000
--- a/packages/editor/src/utils/with-change-detection/test/index.js
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * External dependencies
- */
-import deepFreeze from 'deep-freeze';
-
-/**
- * Internal dependencies
- */
-import withChangeDetection from '../';
-
-describe( 'withChangeDetection()', () => {
- const initialState = deepFreeze( { count: 0 } );
-
- function originalReducer( state = initialState, action ) {
- switch ( action.type ) {
- case 'INCREMENT':
- return {
- count: state.count + 1,
- };
-
- case 'RESET_AND_CHANGE_REFERENCE':
- return {
- count: state.count,
- };
- }
-
- return state;
- }
-
- it( 'should respect original reducer behavior', () => {
- const reducer = withChangeDetection()( originalReducer );
-
- const state = reducer( undefined, {} );
- expect( state ).toEqual( { count: 0, isDirty: false } );
-
- const nextState = reducer( deepFreeze( state ), { type: 'INCREMENT' } );
- expect( nextState ).not.toBe( state );
- expect( nextState ).toEqual( { count: 1, isDirty: true } );
- } );
-
- it( 'should allow reset types as option', () => {
- const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer );
-
- let state;
-
- state = reducer( undefined, {} );
- expect( state ).toEqual( { count: 0, isDirty: false } );
-
- state = reducer( deepFreeze( state ), { type: 'INCREMENT' } );
- expect( state ).toEqual( { count: 1, isDirty: true } );
-
- state = reducer( deepFreeze( state ), { type: 'RESET' } );
- expect( state ).toEqual( { count: 1, isDirty: false } );
- } );
-
- it( 'should allow ignore types as option', () => {
- const reducer = withChangeDetection( { ignoreTypes: [ 'INCREMENT' ] } )( originalReducer );
-
- let state;
-
- state = reducer( undefined, {} );
- expect( state ).toEqual( { count: 0, isDirty: false } );
-
- state = reducer( deepFreeze( state ), { type: 'INCREMENT' } );
- expect( state ).toEqual( { count: 1, isDirty: false } );
- } );
-
- it( 'should preserve isDirty into non-resetting non-reference-changing types', () => {
- const reducer = withChangeDetection( { resetTypes: [ 'RESET' ] } )( originalReducer );
-
- let state;
-
- state = reducer( undefined, {} );
- expect( state ).toEqual( { count: 0, isDirty: false } );
-
- state = reducer( deepFreeze( state ), { type: 'INCREMENT' } );
- expect( state ).toEqual( { count: 1, isDirty: true } );
-
- const afterState = reducer( deepFreeze( state ), {} );
- expect( afterState ).toEqual( { count: 1, isDirty: true } );
- expect( afterState ).toBe( state );
- } );
-
- it( 'should maintain separate states', () => {
- const reducer = withChangeDetection()( originalReducer );
-
- let firstState;
-
- firstState = reducer( undefined, {} );
- expect( firstState ).toEqual( { count: 0, isDirty: false } );
-
- const secondState = reducer( undefined, { type: 'INCREMENT' } );
- expect( secondState ).toEqual( { count: 1, isDirty: false } );
-
- firstState = reducer( deepFreeze( firstState ), {} );
- expect( firstState ).toEqual( { count: 0, isDirty: false } );
- } );
-
- it( 'should flag as not dirty even if reset type causes reference change', () => {
- const reducer = withChangeDetection( { resetTypes: [ 'RESET_AND_CHANGE_REFERENCE' ] } )( originalReducer );
-
- let state;
-
- state = reducer( undefined, {} );
- expect( state ).toEqual( { count: 0, isDirty: false } );
-
- state = reducer( deepFreeze( state ), { type: 'INCREMENT' } );
- expect( state ).toEqual( { count: 1, isDirty: true } );
-
- state = reducer( deepFreeze( state ), { type: 'RESET_AND_CHANGE_REFERENCE' } );
- expect( state ).toEqual( { count: 1, isDirty: false } );
- } );
-} );
diff --git a/packages/editor/src/utils/with-history/README.md b/packages/editor/src/utils/with-history/README.md
deleted file mode 100644
index 3c90ed2d895be8..00000000000000
--- a/packages/editor/src/utils/with-history/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-withHistory
-===========
-
-`withHistory` is a [Redux higher-order reducer](http://redux.js.org/docs/recipes/reducers/ReusingReducerLogic.html#customizing-behavior-with-higher-order-reducers) for tracking the history of a reducer state over time. The enhanced reducer returned from `withHistory` will return an object shape with properties `past`, `present`, and `future`. The `present` value maintains the current value of state returned from the original reducer. Past and future are respectively maintained as arrays of state values occurring previously and future (if history undone).
-
-Leveraging a `resetTypes` option, this can be used to mark intervals at which a state history should be reset, emptying the values of the `past` and `future` arrays.
-
-History can be adjusted by dispatching actions with type `UNDO` (reset to the previous state) and `REDO` (reset to the next state).
-
-## Example
-
-Considering a simple count reducer, we can enhance it with `withHistory` to track value over time:
-
-```js
-function counter( state = { count: 0 }, action ) {
- switch ( action.type ) {
- case 'INCREMENT':
- return { ...state, count: state.count + 1 };
- }
-
- return state;
-}
-
-const enhancedCounter = withHistory( counter, { resetTypes: [ 'RESET' ] } );
-
-let state;
-
-state = enhancedCounter( undefined, {} );
-// { past: [], present: 0, future: [] }
-
-state = enhancedCounter( state, { type: 'INCREMENT' } );
-// { past: [ 0 ], present: 1, future: [] }
-
-state = enhancedCounter( state, { type: 'UNDO' } );
-// { past: [], present: 0, future: [ 1 ] }
-
-state = enhancedCounter( state, { type: 'REDO' } );
-// { past: [ 0 ], present: 1, future: [] }
-
-state = enhancedCounter( state, { type: 'RESET' } );
-// { past: [], present: 1, future: [] }
-```
diff --git a/packages/editor/src/utils/with-history/index.js b/packages/editor/src/utils/with-history/index.js
deleted file mode 100644
index 665851e632bd3f..00000000000000
--- a/packages/editor/src/utils/with-history/index.js
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * External dependencies
- */
-import { overSome, includes, first, last, drop, dropRight } from 'lodash';
-
-/**
- * Default options for withHistory reducer enhancer. Refer to withHistory
- * documentation for options explanation.
- *
- * @see withHistory
- *
- * @type {Object}
- */
-const DEFAULT_OPTIONS = {
- resetTypes: [],
- ignoreTypes: [],
- shouldOverwriteState: () => false,
-};
-
-/**
- * Higher-order reducer creator which transforms the result of the original
- * reducer into an object tracking its own history (past, present, future).
- *
- * @param {?Object} options Optional options.
- * @param {?Array} options.resetTypes Action types upon which to
- * clear past.
- * @param {?Array} options.ignoreTypes Action types upon which to
- * avoid history tracking.
- * @param {?Function} options.shouldOverwriteState Function receiving last and
- * current actions, returning
- * boolean indicating whether
- * present should be merged,
- * rather than add undo level.
- *
- * @return {Function} Higher-order reducer.
- */
-const withHistory = ( options = {} ) => ( reducer ) => {
- options = { ...DEFAULT_OPTIONS, ...options };
-
- // `ignoreTypes` is simply a convenience for `shouldOverwriteState`
- options.shouldOverwriteState = overSome( [
- options.shouldOverwriteState,
- ( action ) => includes( options.ignoreTypes, action.type ),
- ] );
-
- const initialState = {
- past: [],
- present: reducer( undefined, {} ),
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: false,
- };
-
- const {
- resetTypes = [],
- shouldOverwriteState = () => false,
- } = options;
-
- return ( state = initialState, action ) => {
- const { past, present, future, lastAction, shouldCreateUndoLevel } = state;
- const previousAction = lastAction;
-
- switch ( action.type ) {
- case 'UNDO':
- // Can't undo if no past.
- if ( ! past.length ) {
- return state;
- }
-
- return {
- past: dropRight( past ),
- present: last( past ),
- future: [ present, ...future ],
- lastAction: null,
- shouldCreateUndoLevel: false,
- };
- case 'REDO':
- // Can't redo if no future.
- if ( ! future.length ) {
- return state;
- }
-
- return {
- past: [ ...past, present ],
- present: first( future ),
- future: drop( future ),
- lastAction: null,
- shouldCreateUndoLevel: false,
- };
-
- case 'CREATE_UNDO_LEVEL':
- return {
- ...state,
- lastAction: null,
- shouldCreateUndoLevel: true,
- };
- }
-
- const nextPresent = reducer( present, action );
-
- if ( includes( resetTypes, action.type ) ) {
- return {
- past: [],
- present: nextPresent,
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: false,
- };
- }
-
- if ( present === nextPresent ) {
- return state;
- }
-
- let nextPast = past;
- // The `lastAction` property is used to compare actions in the
- // `shouldOverwriteState` option. If an action should be ignored, do not
- // submit that action as the last action, otherwise the ability to
- // compare subsequent actions will break.
- let lastActionToSubmit = previousAction;
-
- if (
- shouldCreateUndoLevel ||
- ! past.length ||
- ! shouldOverwriteState( action, previousAction )
- ) {
- nextPast = [ ...past, present ];
- lastActionToSubmit = action;
- }
-
- return {
- past: nextPast,
- present: nextPresent,
- future: [],
- shouldCreateUndoLevel: false,
- lastAction: lastActionToSubmit,
- };
- };
-};
-
-export default withHistory;
diff --git a/packages/editor/src/utils/with-history/test/index.js b/packages/editor/src/utils/with-history/test/index.js
deleted file mode 100644
index e383988864f77f..00000000000000
--- a/packages/editor/src/utils/with-history/test/index.js
+++ /dev/null
@@ -1,253 +0,0 @@
-/**
- * Internal dependencies
- */
-import withHistory from '../';
-
-describe( 'withHistory', () => {
- const counter = ( state = 0, { type } ) => (
- type === 'INCREMENT' ? state + 1 : state
- );
-
- it( 'should return a new reducer', () => {
- const reducer = withHistory()( counter );
-
- expect( typeof reducer ).toBe( 'function' );
- expect( reducer( undefined, {} ) ).toEqual( {
- past: [],
- present: 0,
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should track history', () => {
- const reducer = withHistory()( counter );
-
- let state;
- const action = { type: 'INCREMENT' };
- state = reducer( undefined, {} );
- state = reducer( state, action );
-
- expect( state ).toEqual( {
- past: [ 0 ],
- present: 1,
- future: [],
- lastAction: action,
- shouldCreateUndoLevel: false,
- } );
-
- state = reducer( state, action );
-
- expect( state ).toEqual( {
- past: [ 0, 1 ],
- present: 2,
- future: [],
- lastAction: action,
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should perform undo', () => {
- const reducer = withHistory()( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
- state = reducer( state, { type: 'UNDO' } );
-
- expect( state ).toEqual( {
- past: [],
- present: 0,
- future: [ 1 ],
- lastAction: null,
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should not perform undo on empty past', () => {
- const reducer = withHistory()( counter );
- const state = reducer( undefined, {} );
-
- expect( state ).toBe( reducer( state, { type: 'UNDO' } ) );
- } );
-
- it( 'should perform redo', () => {
- const reducer = withHistory()( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
- state = reducer( state, { type: 'UNDO' } );
- state = reducer( state, { type: 'REDO' } );
-
- expect( state ).toEqual( {
- past: [ 0 ],
- present: 1,
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should not perform redo on empty future', () => {
- const reducer = withHistory()( counter );
- const state = reducer( undefined, {} );
-
- expect( state ).toBe( reducer( state, { type: 'REDO' } ) );
- } );
-
- it( 'should reset history by options.resetTypes', () => {
- const reducer = withHistory( { resetTypes: [ 'RESET_HISTORY' ] } )( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
- state = reducer( state, { type: 'RESET_HISTORY' } );
-
- expect( state ).toEqual( {
- past: [],
- present: 1,
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should ignore history by options.ignoreTypes', () => {
- const reducer = withHistory( { ignoreTypes: [ 'INCREMENT' ] } )( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
- state = reducer( state, { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ 0 ], // Needs at least one history
- present: 2,
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should return same reference if state has not changed', () => {
- const reducer = withHistory()( counter );
- const original = reducer( undefined, {} );
- const state = reducer( original, {} );
-
- expect( state ).toBe( original );
- } );
-
- it( 'should overwrite present state with option.shouldOverwriteState', () => {
- const reducer = withHistory( {
- shouldOverwriteState: ( { type } ) => type === 'INCREMENT',
- } )( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ 0 ],
- present: 1,
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
-
- state = reducer( state, { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ 0 ],
- present: 2,
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should overwrite present state with option.shouldOverwriteState right after ignored action', () => {
- const complexCounter = ( state = { count: 0 }, action ) => {
- if ( action.type === 'INCREMENT' ) {
- return {
- ...state,
- count: state.count + 1,
- };
- } else if ( action.type === 'IGNORE' ) {
- return {
- ...state,
- ignore: action.content,
- };
- }
-
- return state;
- };
-
- const reducer = withHistory( {
- shouldOverwriteState: ( action, previousAction ) => (
- previousAction && action.type === previousAction.type
- ),
- ignoreTypes: [ 'IGNORE' ],
- } )( complexCounter );
-
- let state = reducer( reducer( undefined, {} ), { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ { count: 0 } ],
- present: { count: 1 },
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
-
- state = reducer( state, { type: 'IGNORE', content: 'ignore' } );
-
- expect( state ).toEqual( {
- past: [ { count: 0 } ],
- present: { count: 1, ignore: 'ignore' },
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
-
- state = reducer( state, { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ { count: 0 } ],
- present: { count: 2, ignore: 'ignore' },
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
- } );
-
- it( 'should create undo level with option.shouldOverwriteState and CREATE_UNDO_LEVEL', () => {
- const reducer = withHistory( {
- shouldOverwriteState: ( { type } ) => type === 'INCREMENT',
- } )( counter );
-
- let state;
- state = reducer( undefined, {} );
- state = reducer( state, { type: 'INCREMENT' } );
- state = reducer( state, { type: 'CREATE_UNDO_LEVEL' } );
-
- expect( state ).toEqual( {
- past: [ 0 ],
- present: 1,
- future: [],
- lastAction: null,
- shouldCreateUndoLevel: true,
- } );
-
- state = reducer( state, { type: 'INCREMENT' } );
-
- expect( state ).toEqual( {
- past: [ 0, 1 ],
- present: 2,
- future: [],
- lastAction: { type: 'INCREMENT' },
- shouldCreateUndoLevel: false,
- } );
- } );
-} );