-
Preview
-
-
+class BlockPreview extends Component {
+ getChildContext() {
+ // Blocks may render their own BlockEdit, in which case we must provide
+ // a mechanism for them to create their own InnerBlockList. BlockEdit
+ // is defined in `@wordpress/blocks`, so to avoid a circular dependency
+ // we inject this function via context.
+ return {
+ createInnerBlockList: ( uid ) => {
+ return createInnerBlockList( uid, noop, noop );
+ },
+ };
+ }
+
+ render() {
+ const { name, attributes } = this.props;
+
+ const block = createBlock( name, attributes );
+
+ return (
+
-
- );
+ );
+ }
}
+BlockPreview.childContextTypes = {
+ createInnerBlockList: noop,
+};
+
export default BlockPreview;
diff --git a/editor/components/block-settings-menu/reusable-block-settings.js b/editor/components/block-settings-menu/reusable-block-settings.js
index 4bcbae2a59c069..d54bf0897d032c 100644
--- a/editor/components/block-settings-menu/reusable-block-settings.js
+++ b/editor/components/block-settings-menu/reusable-block-settings.js
@@ -15,20 +15,10 @@ import { isReusableBlock } from '@wordpress/blocks';
/**
* Internal dependencies
*/
-import {
- getBlock,
- getBlockOrder,
- getReusableBlock,
-} from '../../store/selectors';
+import { getBlock, getReusableBlock } from '../../store/selectors';
import { convertBlockToStatic, convertBlockToReusable, deleteReusableBlock } from '../../store/actions';
-export function ReusableBlockSettings( {
- reusableBlock,
- isValidForConvert,
- onConvertToStatic,
- onConvertToReusable,
- onDelete,
-} ) {
+export function ReusableBlockSettings( { reusableBlock, onConvertToStatic, onConvertToReusable, onDelete } ) {
return (
{ ! reusableBlock && (
@@ -36,7 +26,6 @@ export function ReusableBlockSettings( {
className="editor-block-settings-menu__control"
icon="controls-repeat"
onClick={ onConvertToReusable }
- disabled={ ! isValidForConvert }
>
{ __( 'Convert to Shared Block' ) }
@@ -67,9 +56,7 @@ export function ReusableBlockSettings( {
export default connect(
( state, { uid } ) => {
const block = getBlock( state, uid );
-
return {
- isValidForConvert: ! getBlockOrder( state, block.uid ).length,
reusableBlock: isReusableBlock( block ) ? getReusableBlock( state, block.attributes.ref ) : null,
};
},
diff --git a/editor/components/default-block-appender/style.scss b/editor/components/default-block-appender/style.scss
index b0d2c652b35104..488fcd889f09f3 100644
--- a/editor/components/default-block-appender/style.scss
+++ b/editor/components/default-block-appender/style.scss
@@ -72,6 +72,10 @@ input[type=text].editor-default-block-appender__content {
&:focus {
outline: 1px solid $light-gray-500;
}
+
+ &:disabled {
+ display: none;
+ }
}
.editor-default-block-appender .components-drop-zone__content-icon {
diff --git a/editor/store/actions.js b/editor/store/actions.js
index 686385291b1e20..1c2d66fef3019e 100644
--- a/editor/store/actions.js
+++ b/editor/store/actions.js
@@ -78,6 +78,22 @@ export function resetBlocks( blocks ) {
};
}
+/**
+ * Returns an action object used in signalling that blocks have been received.
+ * Unlike resetBlocks, these should be appended to the existing known set, not
+ * replacing.
+ *
+ * @param {Object[]} blocks Array of block objects.
+ *
+ * @return {Object} Action object.
+ */
+export function receiveBlocks( blocks ) {
+ return {
+ type: 'RECEIVE_BLOCKS',
+ blocks,
+ };
+}
+
/**
* Returns an action object used in signalling that the block attributes with
* the specified UID has been updated.
@@ -453,20 +469,18 @@ export function fetchReusableBlocks( id ) {
}
/**
- * Returns an action object used to insert or update a reusable block into
- * the store.
+ * Returns an action object used in signalling that reusable blocks have been
+ * received. Results is an array of objects containing reusableBlock (details
+ * about reusable persistence) and parsedBlock (the original block).
*
- * @param {Object} id The ID of the reusable block to update.
- * @param {Object} reusableBlock The new reusable block object. Any omitted keys
- * are not changed.
+ * @param {Object[]} results Reusable blocks received.
*
* @return {Object} Action object.
*/
-export function updateReusableBlock( id, reusableBlock ) {
+export function receiveReusableBlocks( results ) {
return {
- type: 'UPDATE_REUSABLE_BLOCK',
- id,
- reusableBlock,
+ type: 'RECEIVE_REUSABLE_BLOCKS',
+ results,
};
}
@@ -499,6 +513,23 @@ export function deleteReusableBlock( id ) {
};
}
+/**
+ * Returns an action object used in signalling that a reusable block's title is
+ * to be updated.
+ *
+ * @param {number} id The ID of the reusable block to update.
+ * @param {string} title The new title.
+ *
+ * @return {Object} Action object.
+ */
+export function updateReusableBlockTitle( id, title ) {
+ return {
+ type: 'UPDATE_REUSABLE_BLOCK_TITLE',
+ id,
+ title,
+ };
+}
+
/**
* Returns an action object used to convert a reusable block into a static
* block.
diff --git a/editor/store/effects.js b/editor/store/effects.js
index c336868b36eaa3..e5a530c4e1ae3b 100644
--- a/editor/store/effects.js
+++ b/editor/store/effects.js
@@ -13,7 +13,6 @@ import {
switchToBlockType,
createBlock,
serialize,
- createReusableBlock,
isReusableBlock,
getDefaultBlockForPostFormat,
} from '@wordpress/blocks';
@@ -27,14 +26,17 @@ import { getPostEditUrl, getWPAdminURL } from '../utils/url';
import {
setupEditorState,
resetPost,
+ receiveBlocks,
+ receiveReusableBlocks,
+ replaceBlock,
replaceBlocks,
createSuccessNotice,
createErrorNotice,
removeNotice,
savePost,
- updateReusableBlock,
saveReusableBlock,
insertBlock,
+ removeBlocks,
selectBlock,
removeBlock,
} from './actions';
@@ -332,8 +334,10 @@ export default {
if ( ! basePath ) {
return;
}
+
const { id } = action;
const { dispatch } = store;
+
let result;
if ( id ) {
result = wp.apiRequest( { path: `/wp/v2/${ basePath }/${ id }` } );
@@ -343,13 +347,17 @@ export default {
result.then(
( reusableBlockOrBlocks ) => {
+ dispatch( receiveReusableBlocks( map(
+ castArray( reusableBlockOrBlocks ),
+ ( reusableBlock ) => ( {
+ reusableBlock,
+ parsedBlock: parse( reusableBlock.content )[ 0 ],
+ } )
+ ) ) );
+
dispatch( {
type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
id,
- reusableBlocks: castArray( reusableBlockOrBlocks ).map( ( { id: itemId, title, content } ) => {
- const [ { name: type, attributes } ] = parse( content );
- return { id: itemId, title, type, attributes };
- } ),
} );
},
( error ) => {
@@ -364,6 +372,9 @@ export default {
}
);
},
+ RECEIVE_REUSABLE_BLOCKS( action ) {
+ return receiveBlocks( map( action.results, 'parsedBlock' ) );
+ },
SAVE_REUSABLE_BLOCK( action, store ) {
// TODO: these are potentially undefined, this fix is in place
// until there is a filter to not use reusable blocks if undefined
@@ -373,10 +384,12 @@ export default {
}
const { id } = action;
- const { getState, dispatch } = store;
+ const { dispatch } = store;
+ const state = store.getState();
- const { title, type, attributes, isTemporary } = getReusableBlock( getState(), id );
- const content = serialize( createBlock( type, attributes ) );
+ const { uid, title, isTemporary } = getReusableBlock( state, id );
+ const { name, attributes, innerBlocks } = getBlock( state, uid );
+ const content = serialize( createBlock( name, attributes, innerBlocks ) );
const data = isTemporary ? { title, content } : { id, title, content };
const path = isTemporary ? `/wp/v2/${ basePath }` : `/wp/v2/${ basePath }/${ id }`;
@@ -429,10 +442,15 @@ export default {
dispatch( {
type: 'REMOVE_REUSABLE_BLOCK',
id,
- associatedBlockUids,
optimist: { type: BEGIN, id: transactionId },
} );
+ // Remove the parsed block.
+ dispatch( removeBlocks( [
+ ...associatedBlockUids,
+ reusableBlock.uid,
+ ] ) );
+
wp.apiRequest( { path: `/wp/v2/${ basePath }/${ id }`, method: 'DELETE' } ).then(
() => {
dispatch( {
@@ -458,25 +476,40 @@ export default {
);
},
CONVERT_BLOCK_TO_STATIC( action, store ) {
- const { getState, dispatch } = store;
-
- const oldBlock = getBlock( getState(), action.uid );
- const reusableBlock = getReusableBlock( getState(), oldBlock.attributes.ref );
- const newBlock = createBlock( reusableBlock.type, reusableBlock.attributes );
- dispatch( replaceBlocks( [ oldBlock.uid ], [ newBlock ] ) );
+ const state = store.getState();
+ const oldBlock = getBlock( state, action.uid );
+ const reusableBlock = getReusableBlock( state, oldBlock.attributes.ref );
+ const referencedBlock = getBlock( state, reusableBlock.uid );
+ const newBlock = createBlock( referencedBlock.name, referencedBlock.attributes );
+ store.dispatch( replaceBlock( oldBlock.uid, newBlock ) );
},
CONVERT_BLOCK_TO_REUSABLE( action, store ) {
const { getState, dispatch } = store;
- const oldBlock = getBlock( getState(), action.uid );
- const reusableBlock = createReusableBlock( oldBlock.name, oldBlock.attributes );
- const newBlock = createBlock( 'core/block', {
- ref: reusableBlock.id,
- layout: oldBlock.attributes.layout,
- } );
- dispatch( updateReusableBlock( reusableBlock.id, reusableBlock ) );
+ const parsedBlock = getBlock( getState(), action.uid );
+ const reusableBlock = {
+ id: uniqueId( 'reusable' ),
+ uid: parsedBlock.uid,
+ title: __( 'Untitled block' ),
+ };
+
+ dispatch( receiveReusableBlocks( [ {
+ reusableBlock,
+ parsedBlock,
+ } ] ) );
+
dispatch( saveReusableBlock( reusableBlock.id ) );
- dispatch( replaceBlocks( [ oldBlock.uid ], [ newBlock ] ) );
+
+ dispatch( replaceBlock(
+ parsedBlock.uid,
+ createBlock( 'core/block', {
+ ref: reusableBlock.id,
+ layout: parsedBlock.attributes.layout,
+ } )
+ ) );
+
+ // Re-add the original block to the store, since replaceBlock() will have removed it
+ dispatch( receiveBlocks( [ parsedBlock ] ) );
},
CREATE_NOTICE( { notice: { content, spokenMessage } } ) {
const message = spokenMessage || content;
diff --git a/editor/store/reducer.js b/editor/store/reducer.js
index 5ae280dce87cac..b64e78ec9d5ac9 100644
--- a/editor/store/reducer.js
+++ b/editor/store/reducer.js
@@ -215,6 +215,7 @@ export const editor = flow( [
// Track undo history, starting at editor initialization.
withHistory( {
resetTypes: [ 'SETUP_EDITOR_STATE' ],
+ ignoreTypes: [ 'RECEIVE_BLOCKS' ],
shouldOverwriteState,
} ),
@@ -222,6 +223,7 @@ export const editor = flow( [
// editor initialization firing post reset as an effect.
withChangeDetection( {
resetTypes: [ 'SETUP_EDITOR_STATE', 'RESET_POST' ],
+ ignoreTypes: [ 'RECEIVE_BLOCKS' ],
} ),
] )( {
edits( state = {}, action ) {
@@ -274,6 +276,12 @@ export const editor = flow( [
case 'SETUP_EDITOR_STATE':
return getFlattenedBlocks( action.blocks );
+ case 'RECEIVE_BLOCKS':
+ return {
+ ...state,
+ ...getFlattenedBlocks( action.blocks ),
+ };
+
case 'UPDATE_BLOCK_ATTRIBUTES':
// Ignore updates if block isn't known
if ( ! state[ action.uid ] ) {
@@ -364,9 +372,6 @@ export const editor = flow( [
return block;
} );
}
-
- case 'REMOVE_REUSABLE_BLOCK':
- return omit( state, action.associatedBlockUids );
}
return state;
@@ -378,6 +383,12 @@ export const editor = flow( [
case 'SETUP_EDITOR_STATE':
return mapBlockOrder( action.blocks );
+ case 'RECEIVE_BLOCKS':
+ return {
+ ...state,
+ ...omit( mapBlockOrder( action.blocks ), '' ),
+ };
+
case 'INSERT_BLOCKS': {
const { rootUID = '', blocks } = action;
@@ -481,20 +492,15 @@ export const editor = flow( [
}
case 'REMOVE_BLOCKS':
- case 'REMOVE_REUSABLE_BLOCK': {
- const { type, uids, associatedBlockUids } = action;
- const uidsToRemove = type === 'REMOVE_BLOCKS' ? uids : associatedBlockUids;
-
return flow( [
// Remove inner block ordering for removed blocks
- ( nextState ) => omit( nextState, uidsToRemove ),
+ ( nextState ) => omit( nextState, action.uids ),
// Remove deleted blocks from other blocks' orderings
( nextState ) => mapValues( nextState, ( subState ) => (
- without( subState, ...uidsToRemove )
+ without( subState, ...action.uids )
) ),
] )( state );
- }
}
return state;
@@ -831,26 +837,37 @@ export function notices( state = [], action ) {
export const reusableBlocks = combineReducers( {
data( state = {}, action ) {
switch ( action.type ) {
- case 'FETCH_REUSABLE_BLOCKS_SUCCESS': {
- return reduce( action.reusableBlocks, ( newState, reusableBlock ) => ( {
- ...newState,
- [ reusableBlock.id ]: reusableBlock,
- } ), state );
+ case 'RECEIVE_REUSABLE_BLOCKS': {
+ return reduce( action.results, ( nextState, result ) => {
+ const { id, title } = result.reusableBlock;
+ const { uid } = result.parsedBlock;
+
+ const value = { uid, title };
+
+ if ( ! isEqual( nextState[ id ], value ) ) {
+ if ( nextState === state ) {
+ nextState = { ...nextState };
+ }
+
+ nextState[ id ] = value;
+ }
+
+ return nextState;
+ }, state );
}
- case 'UPDATE_REUSABLE_BLOCK': {
- const { id, reusableBlock } = action;
- const existingReusableBlock = state[ id ];
+ case 'UPDATE_REUSABLE_BLOCK_TITLE': {
+ const { id, title } = action;
+
+ if ( ! state[ id ] || state[ id ].title === title ) {
+ return state;
+ }
return {
...state,
[ id ]: {
- ...existingReusableBlock,
- ...reusableBlock,
- attributes: {
- ...( existingReusableBlock && existingReusableBlock.attributes ),
- ...reusableBlock.attributes,
- },
+ ...state[ id ],
+ title,
},
};
}
@@ -862,12 +879,11 @@ export const reusableBlocks = combineReducers( {
if ( id === updatedId ) {
return state;
}
+
+ const value = state[ id ];
return {
...omit( state, id ),
- [ updatedId ]: {
- ...omit( state[ id ], [ 'id', 'isTemporary' ] ),
- id: updatedId,
- },
+ [ updatedId ]: value,
};
}
diff --git a/editor/store/selectors.js b/editor/store/selectors.js
index bdd60203a2d572..e41ef42507448c 100644
--- a/editor/store/selectors.js
+++ b/editor/store/selectors.js
@@ -1138,12 +1138,13 @@ function buildInserterItemFromBlockType( state, enabledBlockTypes, blockType ) {
/**
* Given a reusable block, constructs an item that appears in the inserter.
*
+ * @param {Object} state Global application state.
* @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types.
* @param {Object} reusableBlock Reusable block, likely from getReusableBlock().
*
* @return {Editor.InserterItem} Item that appears in inserter.
*/
-function buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock ) {
+function buildInserterItemFromReusableBlock( state, enabledBlockTypes, reusableBlock ) {
if ( ! enabledBlockTypes || ! reusableBlock ) {
return null;
}
@@ -1153,7 +1154,12 @@ function buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock )
return null;
}
- const referencedBlockType = getBlockType( reusableBlock.type );
+ const referencedBlock = getBlock( state, reusableBlock.uid );
+ if ( ! referencedBlock ) {
+ return null;
+ }
+
+ const referencedBlockType = getBlockType( referencedBlock.name );
if ( ! referencedBlockType ) {
return null;
}
@@ -1189,7 +1195,7 @@ export function getInserterItems( state, enabledBlockTypes = true ) {
);
const dynamicItems = getReusableBlocks( state ).map( reusableBlock =>
- buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock )
+ buildInserterItemFromReusableBlock( state, enabledBlockTypes, reusableBlock )
);
const items = [ ...staticItems, ...dynamicItems ];
@@ -1217,7 +1223,7 @@ function getItemsFromInserts( state, inserts, enabledBlockTypes = true, maximum
const items = fillWithCommonBlocks( inserts ).map( insert => {
if ( insert.ref ) {
const reusableBlock = getReusableBlock( state, insert.ref );
- return buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock );
+ return buildInserterItemFromReusableBlock( state, enabledBlockTypes, reusableBlock );
}
const blockType = getBlockType( insert.name );
@@ -1268,20 +1274,36 @@ export function getFrecentInserterItems( state, enabledBlockTypes = true, maximu
/**
* Returns the reusable block with the given ID.
*
- * @param {Object} state Global application state.
- * @param {string} ref The reusable block's ID.
+ * @param {Object} state Global application state.
+ * @param {number|string} ref The reusable block's ID.
*
* @return {Object} The reusable block, or null if none exists.
*/
-export function getReusableBlock( state, ref ) {
- return state.reusableBlocks.data[ ref ] || null;
-}
+export const getReusableBlock = createSelector(
+ ( state, ref ) => {
+ const block = state.reusableBlocks.data[ ref ];
+ if ( ! block ) {
+ return null;
+ }
+
+ const isTemporary = isNaN( parseInt( ref ) );
+
+ return {
+ ...block,
+ id: isTemporary ? ref : +ref,
+ isTemporary,
+ };
+ },
+ ( state, ref ) => [
+ state.reusableBlocks.data[ ref ],
+ ],
+);
/**
* Returns whether or not the reusable block with the given ID is being saved.
*
- * @param {*} state Global application state.
- * @param {*} ref The reusable block's ID.
+ * @param {Object} state Global application state.
+ * @param {string} ref The reusable block's ID.
*
* @return {boolean} Whether or not the reusable block is being saved.
*/
@@ -1289,6 +1311,19 @@ export function isSavingReusableBlock( state, ref ) {
return state.reusableBlocks.isSaving[ ref ] || false;
}
+/**
+ * Returns true if the reusable block with the given ID is being fetched, or
+ * false otherwise.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} ref The reusable block's ID.
+ *
+ * @return {boolean} Whether the reusable block is being fetched.
+ */
+export function isFetchingReusableBlock( state, ref ) {
+ return !! state.reusableBlocks.isFetching[ ref ];
+}
+
/**
* Returns an array of all reusable blocks.
*
@@ -1297,7 +1332,7 @@ export function isSavingReusableBlock( state, ref ) {
* @return {Array} An array of all reusable blocks.
*/
export function getReusableBlocks( state ) {
- return Object.values( state.reusableBlocks.data );
+ return map( state.reusableBlocks.data, ( value, ref ) => getReusableBlock( state, ref ) );
}
/**
diff --git a/editor/store/test/actions.js b/editor/store/test/actions.js
index 1bb16da38d36c6..2d1e6d4b4409b6 100644
--- a/editor/store/test/actions.js
+++ b/editor/store/test/actions.js
@@ -6,7 +6,6 @@ import {
startTyping,
stopTyping,
fetchReusableBlocks,
- updateReusableBlock,
saveReusableBlock,
deleteReusableBlock,
convertBlockToStatic,
@@ -468,25 +467,6 @@ describe( 'actions', () => {
} );
} );
- describe( 'updateReusableBlock', () => {
- it( 'should return the UPDATE_REUSABLE_BLOCK action', () => {
- const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
- const reusableBlock = {
- id,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- },
- };
- expect( updateReusableBlock( id, reusableBlock ) ).toEqual( {
- type: 'UPDATE_REUSABLE_BLOCK',
- id,
- reusableBlock,
- } );
- } );
- } );
-
describe( 'saveReusableBlock', () => {
const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
expect( saveReusableBlock( id ) ).toEqual( {
diff --git a/editor/store/test/effects.js b/editor/store/test/effects.js
index a742701c6096fc..86ceb1574da988 100644
--- a/editor/store/test/effects.js
+++ b/editor/store/test/effects.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { noop, reduce, set } from 'lodash';
+import { noop, set, reduce } from 'lodash';
/**
* WordPress dependencies
@@ -11,7 +11,6 @@ import {
unregisterBlockType,
registerBlockType,
createBlock,
- createReusableBlock,
} from '@wordpress/blocks';
/**
@@ -19,30 +18,27 @@ import {
*/
import {
setupEditorState,
- resetBlocks,
mergeBlocks,
replaceBlocks,
savePost,
- updateReusableBlock,
+ selectBlock,
+ removeBlock,
+ createErrorNotice,
+ fetchReusableBlocks,
+ receiveReusableBlocks,
+ receiveBlocks,
saveReusableBlock,
deleteReusableBlock,
- fetchReusableBlocks,
+ removeBlocks,
+ resetBlocks,
convertBlockToStatic,
convertBlockToReusable,
- selectBlock,
- removeBlock,
- createErrorNotice,
} from '../actions';
-import reducer from '../reducer';
import effects, {
removeProvisionalBlock,
} from '../effects';
import * as selectors from '../selectors';
-
-// Make all generated UUIDs the same for testing
-jest.mock( 'uuid/v4', () => {
- return jest.fn( () => 'this-is-a-mock-uuid' );
-} );
+import reducer from '../reducer';
describe( 'effects', () => {
const defaultBlockSettings = { save: () => 'Saved', category: 'common', title: 'block title' };
@@ -595,80 +591,89 @@ describe( 'effects', () => {
it( 'should fetch multiple reusable blocks', () => {
const promise = Promise.resolve( [
{
- id: 'a9691cf9-ecaa-42bd-a9ca-49587e817647',
+ id: 123,
title: 'My cool block',
- content: '',
+ content: '',
},
] );
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
- set( global, 'wp.apiRequest', () => {
- return promise;
- } );
+ set( global, 'wp.apiRequest', () => promise );
const dispatch = jest.fn();
- const store = { getState: () => {}, dispatch };
+ const store = { getState: noop, dispatch };
handler( fetchReusableBlocks(), store );
return promise.then( () => {
- expect( dispatch ).toHaveBeenCalledWith( {
- type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
- reusableBlocks: [
+ expect( dispatch ).toHaveBeenCalledWith(
+ receiveReusableBlocks( [
{
- id: 'a9691cf9-ecaa-42bd-a9ca-49587e817647',
- title: 'My cool block',
- type: 'core/test-block',
- attributes: {
- name: 'Big Bird',
+ reusableBlock: {
+ id: 123,
+ title: 'My cool block',
+ content: '',
},
+ parsedBlock: expect.objectContaining( {
+ name: 'core/test-block',
+ attributes: { name: 'Big Bird' },
+ } ),
},
- ],
+ ] )
+ );
+ expect( dispatch ).toHaveBeenCalledWith( {
+ type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
+ id: undefined,
} );
} );
} );
it( 'should fetch a single reusable block', () => {
- const id = 123;
-
const promise = Promise.resolve( {
- id,
+ id: 123,
title: 'My cool block',
- content: '',
+ content: '',
} );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', () => promise );
const dispatch = jest.fn();
- const store = { getState: () => {}, dispatch };
+ const store = { getState: noop, dispatch };
- handler( fetchReusableBlocks( id ), store );
+ handler( fetchReusableBlocks( 123 ), store );
return promise.then( () => {
- expect( dispatch ).toHaveBeenCalledWith( {
- type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
- id,
- reusableBlocks: [
+ expect( dispatch ).toHaveBeenCalledWith(
+ receiveReusableBlocks( [
{
- id,
- title: 'My cool block',
- type: 'core/test-block',
- attributes: {
- name: 'Big Bird',
+ reusableBlock: {
+ id: 123,
+ title: 'My cool block',
+ content: '',
},
+ parsedBlock: expect.objectContaining( {
+ name: 'core/test-block',
+ attributes: { name: 'Big Bird' },
+ } ),
},
- ],
+ ] )
+ );
+ expect( dispatch ).toHaveBeenCalledWith( {
+ type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
+ id: 123,
} );
} );
} );
it( 'should handle an API error', () => {
const promise = Promise.reject( {} );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', () => promise );
const dispatch = jest.fn();
- const store = { getState: () => {}, dispatch };
+ const store = { getState: noop, dispatch };
handler( fetchReusableBlocks(), store );
@@ -684,66 +689,86 @@ describe( 'effects', () => {
} );
} );
+ describe( '.RECEIVE_REUSABLE_BLOCKS', () => {
+ const handler = effects.RECEIVE_REUSABLE_BLOCKS;
+
+ it( 'should receive parsed blocks', () => {
+ const action = receiveReusableBlocks( [
+ {
+ parsedBlock: { uid: 'broccoli' },
+ },
+ ] );
+
+ expect( handler( action ) ).toEqual( receiveBlocks( [
+ { uid: 'broccoli' },
+ ] ) );
+ } );
+ } );
+
describe( '.SAVE_REUSABLE_BLOCK', () => {
const handler = effects.SAVE_REUSABLE_BLOCK;
- it( 'should save a reusable block and swaps its id', () => {
+ it( 'should save a reusable block and swap its id', () => {
let modelAttributes;
- const promise = Promise.resolve( { id: 3 } );
+ const promise = Promise.resolve( { id: 456 } );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', ( request ) => {
modelAttributes = request.data;
return promise;
} );
- const reusableBlock = createReusableBlock( 'core/test-block', {
- name: 'Big Bird',
- } );
+ const reusableBlock = { id: 123, title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
- const initialState = reducer( undefined, {} );
- const action = updateReusableBlock( reusableBlock.id, reusableBlock );
- const state = reducer( initialState, action );
+ const state = reduce( [
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
- handler( saveReusableBlock( reusableBlock.id ), store );
+ handler( saveReusableBlock( 123 ), store );
expect( modelAttributes ).toEqual( {
- title: 'Untitled block',
+ id: 123,
+ title: 'My cool block',
content: '',
} );
+
return promise.then( () => {
expect( dispatch ).toHaveBeenCalledWith( {
type: 'SAVE_REUSABLE_BLOCK_SUCCESS',
- id: reusableBlock.id,
- updatedId: 3,
+ id: 123,
+ updatedId: 456,
} );
} );
} );
it( 'should handle an API error', () => {
const promise = Promise.reject( {} );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', () => promise );
- const reusableBlock = createReusableBlock( 'core/test-block', {
- name: 'Big Bird',
- } );
+ const reusableBlock = { id: 123, title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
- const initialState = reducer( undefined, {} );
- const action = updateReusableBlock( reusableBlock.id, reusableBlock );
- const state = reducer( initialState, action );
+ const state = reduce( [
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
- handler( saveReusableBlock( reusableBlock.id ), store );
+ handler( saveReusableBlock( 123 ), store );
return promise.catch( () => {
expect( dispatch ).toHaveBeenCalledWith( {
type: 'SAVE_REUSABLE_BLOCK_FAILURE',
- id: reusableBlock.id,
+ id: 123,
} );
} );
} );
@@ -754,36 +779,39 @@ describe( 'effects', () => {
it( 'should delete a reusable block', () => {
const promise = Promise.resolve( {} );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', () => promise );
- const id = 123;
+ const associatedBlock = createBlock( 'core/block', { ref: 123 } );
+ const reusableBlock = { id: 123, title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
- const associatedBlock = createBlock( 'core/block', {
- ref: id,
- } );
-
- const actions = [
+ const state = reduce( [
resetBlocks( [ associatedBlock ] ),
- updateReusableBlock( id, {} ),
- ];
- const state = actions.reduce( reducer, undefined );
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
- handler( deleteReusableBlock( id ), store );
+ handler( deleteReusableBlock( 123 ), store );
expect( dispatch ).toHaveBeenCalledWith( {
type: 'REMOVE_REUSABLE_BLOCK',
- id,
- associatedBlockUids: [ associatedBlock.uid ],
+ id: 123,
optimist: expect.any( Object ),
} );
+
+ expect( dispatch ).toHaveBeenCalledWith(
+ removeBlocks( [ associatedBlock.uid, parsedBlock.uid ] )
+ );
+
return promise.then( () => {
expect( dispatch ).toHaveBeenCalledWith( {
type: 'DELETE_REUSABLE_BLOCK_SUCCESS',
- id,
+ id: 123,
optimist: expect.any( Object ),
} );
} );
@@ -791,10 +819,17 @@ describe( 'effects', () => {
it( 'should handle an API error', () => {
const promise = Promise.reject( {} );
+
set( global, 'wp.api.getPostTypeRoute', () => 'blocks' );
set( global, 'wp.apiRequest', () => promise );
- const state = reducer( undefined, updateReusableBlock( 123, {} ) );
+ const reusableBlock = { id: 123, title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
+
+ const state = reduce( [
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
@@ -811,17 +846,18 @@ describe( 'effects', () => {
} );
it( 'should not save reusable blocks with temporary IDs', () => {
- const reusableBlock = {
- id: -123,
- isTemporary: true,
- };
+ const reusableBlock = { id: 'reusable1', title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
- const state = reducer( undefined, updateReusableBlock( -123, reusableBlock ) );
+ const state = reduce( [
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
- handler( deleteReusableBlock( -123 ), store );
+ handler( deleteReusableBlock( 'reusable1' ), store );
expect( dispatch ).not.toHaveBeenCalled();
} );
@@ -831,30 +867,30 @@ describe( 'effects', () => {
const handler = effects.CONVERT_BLOCK_TO_STATIC;
it( 'should convert a reusable block into a static block', () => {
- const reusableBlock = createReusableBlock( 'core/test-block', {
- name: 'Big Bird',
- } );
- const staticBlock = createBlock( 'core/block', {
- ref: reusableBlock.id,
- } );
+ const associatedBlock = createBlock( 'core/block', { ref: 123 } );
+ const reusableBlock = { id: 123, title: 'My cool block' };
+ const parsedBlock = createBlock( 'core/test-block', { name: 'Big Bird' } );
- const actions = [
- resetBlocks( [ staticBlock ] ),
- updateReusableBlock( reusableBlock.id, reusableBlock ),
- ];
- const initialState = reducer( undefined, {} );
- const state = reduce( actions, reducer, initialState );
+ const state = reduce( [
+ resetBlocks( [ associatedBlock ] ),
+ receiveReusableBlocks( [ { reusableBlock, parsedBlock } ] ),
+ receiveBlocks( [ parsedBlock ] ),
+ ], reducer, undefined );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
- handler( convertBlockToStatic( staticBlock.uid ), store );
+ handler( convertBlockToStatic( associatedBlock.uid ), store );
expect( dispatch ).toHaveBeenCalledWith( {
- ...replaceBlocks(
- [ staticBlock.uid ],
- createBlock( reusableBlock.type, reusableBlock.attributes )
- ),
+ type: 'REPLACE_BLOCKS',
+ uids: [ associatedBlock.uid ],
+ blocks: [
+ expect.objectContaining( {
+ name: 'core/test-block',
+ attributes: { name: 'Big Bird' },
+ } ),
+ ],
time: expect.any( Number ),
} );
} );
@@ -864,12 +900,8 @@ describe( 'effects', () => {
const handler = effects.CONVERT_BLOCK_TO_REUSABLE;
it( 'should convert a static block into a reusable block', () => {
- const staticBlock = createBlock( 'core/test-block', {
- name: 'Big Bird',
- } );
-
- const initialState = reducer( undefined, {} );
- const state = reducer( initialState, resetBlocks( [ staticBlock ] ) );
+ const staticBlock = createBlock( 'core/block', { ref: 123 } );
+ const state = reducer( undefined, resetBlocks( [ staticBlock ] ) );
const dispatch = jest.fn();
const store = { getState: () => state, dispatch };
@@ -877,24 +909,35 @@ describe( 'effects', () => {
handler( convertBlockToReusable( staticBlock.uid ), store );
expect( dispatch ).toHaveBeenCalledWith(
- updateReusableBlock( expect.any( Number ), {
- id: expect.any( Number ),
- isTemporary: true,
- title: 'Untitled block',
- type: staticBlock.name,
- attributes: staticBlock.attributes,
- } )
+ receiveReusableBlocks( [ {
+ reusableBlock: {
+ id: expect.stringMatching( /^reusable/ ),
+ uid: staticBlock.uid,
+ title: 'Untitled block',
+ },
+ parsedBlock: staticBlock,
+ } ] )
);
+
expect( dispatch ).toHaveBeenCalledWith(
- saveReusableBlock( expect.any( Number ) )
+ saveReusableBlock( expect.stringMatching( /^reusable/ ) ),
);
+
expect( dispatch ).toHaveBeenCalledWith( {
- ...replaceBlocks(
- [ staticBlock.uid ],
- [ createBlock( 'core/block', { ref: expect.any( Number ) } ) ]
- ),
+ type: 'REPLACE_BLOCKS',
+ uids: [ staticBlock.uid ],
+ blocks: [
+ expect.objectContaining( {
+ name: 'core/block',
+ attributes: { ref: expect.stringMatching( /^reusable/ ) },
+ } ),
+ ],
time: expect.any( Number ),
} );
+
+ expect( dispatch ).toHaveBeenCalledWith(
+ receiveBlocks( [ staticBlock ] ),
+ );
} );
} );
} );
diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js
index f9dd758da97a3b..4789158941b0ed 100644
--- a/editor/store/test/reducer.js
+++ b/editor/store/test/reducer.js
@@ -881,37 +881,6 @@ describe( 'state', () => {
expect( state.present.blockOrder[ '' ] ).toEqual( [ 'kumquat', 'persimmon', 'loquat' ] );
} );
- it( 'should remove associated blocks when deleting a reusable block', () => {
- const original = editor( undefined, {
- type: 'RESET_BLOCKS',
- blocks: [ {
- uid: 'chicken',
- name: 'core/test-block',
- attributes: {},
- innerBlocks: [],
- }, {
- uid: 'ribs',
- name: 'core/test-block',
- attributes: {},
- innerBlocks: [],
- } ],
- } );
- const state = editor( original, {
- type: 'REMOVE_REUSABLE_BLOCK',
- id: 123,
- associatedBlockUids: [ 'chicken', 'veggies' ],
- } );
-
- expect( state.present.blockOrder[ '' ] ).toEqual( [ 'ribs' ] );
- expect( state.present.blocksByUid ).toEqual( {
- ribs: {
- uid: 'ribs',
- name: 'core/test-block',
- attributes: {},
- },
- } );
- } );
-
describe( 'edits()', () => {
it( 'should save newly edited properties', () => {
const original = editor( undefined, {
@@ -1811,49 +1780,23 @@ describe( 'state', () => {
} );
} );
- it( 'should add fetched reusable blocks', () => {
- const reusableBlock = {
- id: 123,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- },
- };
-
+ it( 'should add received reusable blocks', () => {
const state = reusableBlocks( {}, {
- type: 'FETCH_REUSABLE_BLOCKS_SUCCESS',
- reusableBlocks: [ reusableBlock ],
- } );
-
- expect( state ).toEqual( {
- data: {
- [ reusableBlock.id ]: reusableBlock,
- },
- isFetching: {},
- isSaving: {},
- } );
- } );
-
- it( 'should add a reusable block', () => {
- const reusableBlock = {
- id: 123,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- },
- };
-
- const state = reusableBlocks( {}, {
- type: 'UPDATE_REUSABLE_BLOCK',
- id: reusableBlock.id,
- reusableBlock,
+ type: 'RECEIVE_REUSABLE_BLOCKS',
+ results: [ {
+ reusableBlock: {
+ id: 123,
+ title: 'My cool block',
+ },
+ parsedBlock: {
+ uid: 'foo',
+ },
+ } ],
} );
expect( state ).toEqual( {
data: {
- [ reusableBlock.id ]: reusableBlock,
+ 123: { uid: 'foo', title: 'My cool block' },
},
isFetching: {},
isSaving: {},
@@ -1861,45 +1804,23 @@ describe( 'state', () => {
} );
it( 'should update a reusable block', () => {
- const id = 123;
const initialState = {
data: {
- [ id ]: {
- id,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- dropCap: true,
- },
- },
+ 123: { uid: '', title: '' },
},
isFetching: {},
isSaving: {},
};
const state = reusableBlocks( initialState, {
- type: 'UPDATE_REUSABLE_BLOCK',
- id,
- reusableBlock: {
- name: 'My better block',
- attributes: {
- content: 'Yo!',
- },
- },
+ type: 'UPDATE_REUSABLE_BLOCK_TITLE',
+ id: 123,
+ title: 'My block',
} );
expect( state ).toEqual( {
data: {
- [ id ]: {
- id,
- name: 'My better block',
- type: 'core/paragraph',
- attributes: {
- content: 'Yo!',
- dropCap: true,
- },
- },
+ 123: { uid: '', title: 'My block' },
},
isFetching: {},
isSaving: {},
@@ -1907,40 +1828,22 @@ describe( 'state', () => {
} );
it( 'should update the reusable block\'s id if it was temporary', () => {
- const id = 123;
const initialState = {
data: {
- [ id ]: {
- id,
- isTemporary: true,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- dropCap: true,
- },
- },
+ reusable1: { uid: '', title: '' },
},
isSaving: {},
};
const state = reusableBlocks( initialState, {
type: 'SAVE_REUSABLE_BLOCK_SUCCESS',
- id,
- updatedId: 3,
+ id: 'reusable1',
+ updatedId: 123,
} );
expect( state ).toEqual( {
data: {
- 3: {
- id: 3,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
- dropCap: true,
- },
- },
+ 123: { uid: '', title: '' },
},
isFetching: {},
isSaving: {},
diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js
index 3c0b36a48872ab..99923ebbc92b47 100644
--- a/editor/store/test/selectors.js
+++ b/editor/store/test/selectors.js
@@ -67,6 +67,7 @@ const {
getNotices,
getReusableBlock,
isSavingReusableBlock,
+ isFetchingReusableBlock,
isSelectionEnabled,
getReusableBlocks,
getStateBeforeOptimisticTransaction,
@@ -2379,7 +2380,9 @@ describe( 'selectors', () => {
const state = {
editor: {
present: {
- blocksByUid: {},
+ blocksByUid: {
+ carrot: { name: 'core/test-block' },
+ },
blockOrder: {},
edits: {},
},
@@ -2387,11 +2390,7 @@ describe( 'selectors', () => {
currentPost: {},
reusableBlocks: {
data: {
- 123: {
- id: 123,
- title: 'My reusable block',
- type: 'core/test-block',
- },
+ 123: { uid: 'carrot', title: 'My reusable block' },
},
},
};
@@ -2433,14 +2432,19 @@ describe( 'selectors', () => {
},
editor: {
present: {
+ blocksByUid: {
+ carrot: { name: 'core/test-block' },
+ },
blockOrder: [],
+ edits: {},
},
},
reusableBlocks: {
data: {
- 123: { id: 123, type: 'core/test-block' },
+ 123: { uid: 'carrot' },
},
},
+ currentPost: {},
};
expect( getFrecentInserterItems( state, true, 3 ) ).toMatchObject( [
@@ -2521,25 +2525,45 @@ describe( 'selectors', () => {
describe( 'getReusableBlock', () => {
it( 'should return a reusable block', () => {
- const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
- const expectedReusableBlock = {
- id,
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
+ const state = {
+ reusableBlocks: {
+ data: {
+ 8109: {
+ uid: 'foo',
+ title: 'My cool block',
+ },
+ },
},
};
+
+ const actualReusableBlock = getReusableBlock( state, 8109 );
+ expect( actualReusableBlock ).toEqual( {
+ id: 8109,
+ isTemporary: false,
+ uid: 'foo',
+ title: 'My cool block',
+ } );
+ } );
+
+ it( 'should return a temporary reusable block', () => {
const state = {
reusableBlocks: {
data: {
- [ id ]: expectedReusableBlock,
+ reusable1: {
+ uid: 'foo',
+ title: 'My cool block',
+ },
},
},
};
- const actualReusableBlock = getReusableBlock( state, id );
- expect( actualReusableBlock ).toEqual( expectedReusableBlock );
+ const actualReusableBlock = getReusableBlock( state, 'reusable1' );
+ expect( actualReusableBlock ).toEqual( {
+ id: 'reusable1',
+ isTemporary: true,
+ uid: 'foo',
+ title: 'My cool block',
+ } );
} );
it( 'should return null when no reusable block exists', () => {
@@ -2562,54 +2586,66 @@ describe( 'selectors', () => {
},
};
- const isSaving = isSavingReusableBlock( state, '358b59ee-bab3-4d6f-8445-e8c6971a5605' );
+ const isSaving = isSavingReusableBlock( state, 5187 );
expect( isSaving ).toBe( false );
} );
it( 'should return true when the block is being saved', () => {
- const id = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
const state = {
reusableBlocks: {
isSaving: {
- [ id ]: true,
+ 5187: true,
},
},
};
- const isSaving = isSavingReusableBlock( state, id );
+ const isSaving = isSavingReusableBlock( state, 5187 );
expect( isSaving ).toBe( true );
} );
} );
- describe( 'getReusableBlocks', () => {
- it( 'should return an array of reusable blocks', () => {
- const reusableBlock1 = {
- id: '358b59ee-bab3-4d6f-8445-e8c6971a5605',
- name: 'My cool block',
- type: 'core/paragraph',
- attributes: {
- content: 'Hello!',
+ describe( 'isFetchingReusableBlock', () => {
+ it( 'should return false when the block is not being fetched', () => {
+ const state = {
+ reusableBlocks: {
+ isFetching: {},
},
};
- const reusableBlock2 = {
- id: '687e1a87-cca1-41f2-a782-197ddaea9abf',
- name: 'My neat block',
- type: 'core/paragraph',
- attributes: {
- content: 'Goodbye!',
+
+ const isFetching = isFetchingReusableBlock( state, 5187 );
+ expect( isFetching ).toBe( false );
+ } );
+
+ it( 'should return true when the block is being fetched', () => {
+ const state = {
+ reusableBlocks: {
+ isFetching: {
+ 5187: true,
+ },
},
};
+
+ const isFetching = isFetchingReusableBlock( state, 5187 );
+ expect( isFetching ).toBe( true );
+ } );
+ } );
+
+ describe( 'getReusableBlocks', () => {
+ it( 'should return an array of reusable blocks', () => {
const state = {
reusableBlocks: {
data: {
- [ reusableBlock1.id ]: reusableBlock1,
- [ reusableBlock2.id ]: reusableBlock2,
+ 123: { uid: 'carrot' },
+ reusable1: { uid: 'broccoli' },
},
},
};
const reusableBlocks = getReusableBlocks( state );
- expect( reusableBlocks ).toEqual( [ reusableBlock1, reusableBlock2 ] );
+ expect( reusableBlocks ).toEqual( [
+ { id: 123, isTemporary: false, uid: 'carrot' },
+ { id: 'reusable1', isTemporary: true, uid: 'broccoli' },
+ ] );
} );
it( 'should return an empty array when no reusable blocks exist', () => {
diff --git a/editor/components/block-list/utils.js b/editor/utils/block-list.js
similarity index 97%
rename from editor/components/block-list/utils.js
rename to editor/utils/block-list.js
index 67a1969753739f..74ea7a3c69ad58 100644
--- a/editor/components/block-list/utils.js
+++ b/editor/utils/block-list.js
@@ -6,7 +6,7 @@ import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
-import BlockList from './';
+import BlockList from '../components/block-list';
/**
* An object of cached BlockList components
diff --git a/editor/utils/with-change-detection/index.js b/editor/utils/with-change-detection/index.js
index f5213642096e97..8b738e50015d66 100644
--- a/editor/utils/with-change-detection/index.js
+++ b/editor/utils/with-change-detection/index.js
@@ -8,8 +8,9 @@ import { includes } from 'lodash';
* 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.resetTypes Action types upon which to reset dirty.
+ * @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.
*/
@@ -17,6 +18,10 @@ const withChangeDetection = ( options = {} ) => ( reducer ) => {
return ( state, action ) => {
const nextState = reducer( state, action );
+ if ( includes( options.ignoreTypes, action.type ) ) {
+ return nextState;
+ }
+
// Reset at:
// - Initial state
// - Reset types
diff --git a/editor/utils/with-change-detection/test/index.js b/editor/utils/with-change-detection/test/index.js
index fb00d3b66ead81..728ef8b22a5500 100644
--- a/editor/utils/with-change-detection/test/index.js
+++ b/editor/utils/with-change-detection/test/index.js
@@ -49,6 +49,18 @@ describe( 'withChangeDetection()', () => {
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 );
diff --git a/editor/utils/with-history/index.js b/editor/utils/with-history/index.js
index 364a0bc29d6fdc..cc90f0d4b66478 100644
--- a/editor/utils/with-history/index.js
+++ b/editor/utils/with-history/index.js
@@ -1,18 +1,48 @@
/**
* External dependencies
*/
-import { includes, first, last, drop, dropRight } from 'lodash';
+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 {?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, {} ),
diff --git a/editor/utils/with-history/test/index.js b/editor/utils/with-history/test/index.js
index c7730786688ac9..10757a09471468 100644
--- a/editor/utils/with-history/test/index.js
+++ b/editor/utils/with-history/test/index.js
@@ -101,6 +101,21 @@ describe( 'withHistory', () => {
} );
} );
+ 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: [],
+ } );
+ } );
+
it( 'should return same reference if state has not changed', () => {
const reducer = withHistory()( counter );
const original = reducer( undefined, {} );