diff --git a/blocks/api/factory.js b/blocks/api/factory.js index b58b8919e1cfbc..fa63fb1cc0f1c8 100644 --- a/blocks/api/factory.js +++ b/blocks/api/factory.js @@ -14,13 +14,11 @@ import { find, first, flatMap, - uniqueId, } from 'lodash'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; import { applyFilters } from '@wordpress/hooks'; /** @@ -257,23 +255,3 @@ export function switchToBlockType( blocks, name ) { return applyFilters( 'blocks.switchToBlockType.transformedBlock', transformedBlock, blocks ); } ); } - -/** - * Creates a new reusable block. - * - * @param {string} type The type of the block referenced by the reusable - * block. - * @param {Object} attributes The attributes of the block referenced by the - * reusable block. - * - * @return {Object} A reusable block object. - */ -export function createReusableBlock( type, attributes ) { - return { - id: -uniqueId(), // Temporary id replaced when the block is saved server side - isTemporary: true, - title: __( 'Untitled block' ), - type, - attributes, - }; -} diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index e24a0a726a6d0f..1726b4218d528c 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -12,7 +12,6 @@ import { cloneBlock, getPossibleBlockTransformations, switchToBlockType, - createReusableBlock, } from '../factory'; import { getBlockTypes, unregisterBlockType, setUnknownTypeHandlerName, registerBlockType } from '../registration'; @@ -700,18 +699,4 @@ describe( 'block factory', () => { } ); } ); } ); - - describe( 'createReusableBlock', () => { - it( 'should create a reusable block', () => { - const type = 'core/test-block'; - const attributes = { name: 'Big Bird' }; - - expect( createReusableBlock( type, attributes ) ).toMatchObject( { - id: expect.any( Number ), - title: 'Untitled block', - type, - attributes, - } ); - } ); - } ); } ); diff --git a/blocks/block-edit/index.js b/blocks/block-edit/index.js index 958e495454a69c..6b26ceed85eab9 100644 --- a/blocks/block-edit/index.js +++ b/blocks/block-edit/index.js @@ -2,12 +2,14 @@ * External dependencies */ import classnames from 'classnames'; -import { noop } from 'lodash'; +import { noop, get } from 'lodash'; /** * WordPress dependencies */ -import { withFilters } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; +import { Component, compose } from '@wordpress/element'; +import { withContext, withFilters, withAPIData } from '@wordpress/components'; /** * Internal dependencies @@ -18,35 +20,67 @@ import { hasBlockSupport, } from '../api'; -export function BlockEdit( props ) { - const { name, attributes = {} } = props; - const blockType = getBlockType( name ); +export class BlockEdit extends Component { + getChildContext() { + const { + id: uid, + user, + createInnerBlockList, + } = this.props; - if ( ! blockType ) { - return null; + return { + BlockList: createInnerBlockList( uid ), + canUserUseUnfilteredHTML: get( user.data, [ + 'capabilities', + 'unfiltered_html', + ], false ), + }; } - // Generate a class name for the block's editable form - const generatedClassName = hasBlockSupport( blockType, 'className', true ) ? - getBlockDefaultClassname( name ) : - null; - const className = classnames( generatedClassName, attributes.className ); - - // `edit` and `save` are functions or components describing the markup - // with which a block is displayed. If `blockType` is valid, assign - // them preferencially as the render value for the block. - const Edit = blockType.edit || blockType.save; - - // For backwards compatibility concerns adds a focus and setFocus prop - // These should be removed after some time (maybe when merging to Core) - return ( - - ); + render() { + const { name, attributes = {}, isSelected } = this.props; + const blockType = getBlockType( name ); + + if ( ! blockType ) { + return null; + } + + // Generate a class name for the block's editable form + const generatedClassName = hasBlockSupport( blockType, 'className', true ) ? + getBlockDefaultClassname( name ) : + null; + const className = classnames( generatedClassName, attributes.className ); + + // `edit` and `save` are functions or components describing the markup + // with which a block is displayed. If `blockType` is valid, assign + // them preferentially as the render value for the block. + const Edit = blockType.edit || blockType.save; + + // For backwards compatibility concerns adds a focus and setFocus prop + // These should be removed after some time (maybe when merging to Core) + return ( + + ); + } } -export default withFilters( 'blocks.BlockEdit' )( BlockEdit ); +BlockEdit.childContextTypes = { + BlockList: noop, + canUserUseUnfilteredHTML: noop, +}; + +export default compose( [ + withFilters( 'blocks.BlockEdit' ), + withSelect( ( select ) => ( { + postType: select( 'core/editor' ).getEditedPostAttribute( 'type' ), + } ) ), + withAPIData( ( { postType } ) => ( { + user: `/wp/v2/users/me?post_type=${ postType }&context=edit`, + } ) ), + withContext( 'createInnerBlockList' )(), +] )( BlockEdit ); diff --git a/blocks/library/block/index.js b/blocks/library/block/index.js index b591bc38892aa9..38ba7ee816afc6 100644 --- a/blocks/library/block/index.js +++ b/blocks/library/block/index.js @@ -1,14 +1,14 @@ /** * External dependencies */ -import { pickBy, noop } from 'lodash'; -import { connect } from 'react-redux'; +import { noop, partial } from 'lodash'; /** * WordPress dependencies */ -import { Component, Fragment } from '@wordpress/element'; +import { Component, Fragment, compose } from '@wordpress/element'; import { Placeholder, Spinner, Disabled } from '@wordpress/components'; +import { withSelect, withDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; /** @@ -25,12 +25,11 @@ class ReusableBlockEdit extends Component { this.stopEditing = this.stopEditing.bind( this ); this.setAttributes = this.setAttributes.bind( this ); this.setTitle = this.setTitle.bind( this ); - this.updateReusableBlock = this.updateReusableBlock.bind( this ); + this.save = this.save.bind( this ); this.state = { isEditing: !! ( reusableBlock && reusableBlock.isTemporary ), title: null, - attributes: null, }; } @@ -40,68 +39,63 @@ class ReusableBlockEdit extends Component { } } - componentWillReceiveProps( nextProps ) { - if ( this.props.focus && ! nextProps.focus ) { - this.stopEditing(); - } - } - startEditing() { - this.setState( { isEditing: true } ); + const { reusableBlock } = this.props; + + this.setState( { + isEditing: true, + title: reusableBlock.title, + } ); } stopEditing() { this.setState( { isEditing: false, title: null, - attributes: null, } ); } setAttributes( attributes ) { - this.setState( ( prevState ) => ( { - attributes: { ...prevState.attributes, ...attributes }, - } ) ); + const { updateAttributes, block } = this.props; + updateAttributes( block.uid, attributes ); } setTitle( title ) { this.setState( { title } ); } - updateReusableBlock() { - const { title, attributes } = this.state; + save() { + const { reusableBlock, onUpdateTitle, onSave } = this.props; - // Use pickBy to include only changed (assigned) values in payload - const payload = pickBy( { - title, - attributes, - } ); + const { title } = this.state; + if ( title !== reusableBlock.title ) { + onUpdateTitle( title ); + } + + onSave(); - this.props.updateReusableBlock( payload ); - this.props.saveReusableBlock(); this.stopEditing(); } render() { - const { isSelected, reusableBlock, isFetching, isSaving } = this.props; - const { isEditing, title, attributes } = this.state; + const { isSelected, reusableBlock, block, isFetching, isSaving } = this.props; + const { isEditing, title } = this.state; if ( ! reusableBlock && isFetching ) { return ; } - if ( ! reusableBlock ) { + if ( ! reusableBlock || ! block ) { return { __( 'Block has been deleted or is unavailable.' ) }; } - const reusableBlockAttributes = { ...reusableBlock.attributes, ...attributes }; - let element = ( ); @@ -113,14 +107,14 @@ class ReusableBlockEdit extends Component { return ( { element } - { isSelected && ( + { ( isSelected || isEditing ) && ( ) } @@ -129,34 +123,41 @@ class ReusableBlockEdit extends Component { } } -const ConnectedReusableBlockEdit = connect( - ( state, ownProps ) => ( { - reusableBlock: state.reusableBlocks.data[ ownProps.attributes.ref ], - isFetching: state.reusableBlocks.isFetching[ ownProps.attributes.ref ], - isSaving: state.reusableBlocks.isSaving[ ownProps.attributes.ref ], +const EnhancedReusableBlockEdit = compose( [ + withSelect( ( select, ownProps ) => { + const { + getReusableBlock, + isFetchingReusableBlock, + isSavingReusableBlock, + getBlock, + } = select( 'core/editor' ); + const { ref } = ownProps.attributes; + const reusableBlock = getReusableBlock( ref ); + + return { + reusableBlock, + isFetching: isFetchingReusableBlock( ref ), + isSaving: isSavingReusableBlock( ref ), + block: reusableBlock ? getBlock( reusableBlock.uid ) : null, + }; } ), - ( dispatch, ownProps ) => ( { - fetchReusableBlock() { - dispatch( { - type: 'FETCH_REUSABLE_BLOCKS', - id: ownProps.attributes.ref, - } ); - }, - updateReusableBlock( reusableBlock ) { - dispatch( { - type: 'UPDATE_REUSABLE_BLOCK', - id: ownProps.attributes.ref, - reusableBlock, - } ); - }, - saveReusableBlock() { - dispatch( { - type: 'SAVE_REUSABLE_BLOCK', - id: ownProps.attributes.ref, - } ); - }, - } ) -)( ReusableBlockEdit ); + withDispatch( ( dispatch, ownProps ) => { + const { + fetchReusableBlocks, + updateBlockAttributes, + updateReusableBlockTitle, + saveReusableBlock, + } = dispatch( 'core/editor' ); + const { ref } = ownProps.attributes; + + return { + fetchReusableBlock: partial( fetchReusableBlocks, ref ), + updateAttributes: updateBlockAttributes, + onUpdateTitle: partial( updateReusableBlockTitle, ref ), + onSave: partial( saveReusableBlock, ref ), + }; + } ), +] )( ReusableBlockEdit ); export const name = 'core/block'; @@ -176,6 +177,6 @@ export const settings = { html: false, }, - edit: ConnectedReusableBlockEdit, + edit: EnhancedReusableBlockEdit, save: () => null, }; diff --git a/blocks/test/helpers/index.js b/blocks/test/helpers/index.js index d5386212e91483..f46573240df852 100644 --- a/blocks/test/helpers/index.js +++ b/blocks/test/helpers/index.js @@ -11,8 +11,8 @@ import { createBlock, getBlockType, registerBlockType, - BlockEdit, } from '../..'; +import { BlockEdit } from '../../block-edit'; export const blockEditRender = ( name, settings ) => { if ( ! getBlockType( name ) ) { @@ -26,6 +26,8 @@ export const blockEditRender = ( name, settings ) => { isSelected={ false } attributes={ block.attributes } setAttributes={ noop } + user={ {} } + createInnerBlockList={ noop } /> ); }; diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index c00d95feaad70e..ec4059d9e1924d 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -3,7 +3,7 @@ */ import { connect } from 'react-redux'; import classnames from 'classnames'; -import { get, reduce, size, castArray, noop, first, last } from 'lodash'; +import { get, reduce, size, castArray, first, last, noop } from 'lodash'; import tinymce from 'tinymce'; /** @@ -26,7 +26,7 @@ import { isReusableBlock, isUnmodifiedDefaultBlock, } from '@wordpress/blocks'; -import { withFilters, withContext, withAPIData } from '@wordpress/components'; +import { withFilters, withContext } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; /** @@ -46,7 +46,7 @@ import BlockInsertionPoint from './insertion-point'; import IgnoreNestedEvents from './ignore-nested-events'; import InserterWithShortcuts from '../inserter-with-shortcuts'; import Inserter from '../inserter'; -import { createInnerBlockList } from './utils'; +import { createInnerBlockList } from '../../utils/block-list'; import { editPost, insertBlocks, @@ -70,7 +70,6 @@ import { isSelectionEnabled, isTyping, getBlockMode, - getCurrentPostType, getSelectedBlocksInitialCaretPosition, } from '../../store/selectors'; @@ -103,20 +102,21 @@ export class BlockListBlock extends Component { }; } + /** + * Provides context for descendent components for use in block rendering. + * + * @return {Object} Child context. + */ getChildContext() { - const { - uid, - renderBlockMenu, - showContextualToolbar, - } = this.props; - + // 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 { - BlockList: createInnerBlockList( - uid, - renderBlockMenu, - showContextualToolbar - ), - canUserUseUnfilteredHTML: get( this.props.user, [ 'data', 'capabilities', 'unfiltered_html' ], false ), + createInnerBlockList: ( uid ) => { + const { renderBlockMenu, showContextualToolbar } = this.props; + return createInnerBlockList( uid, renderBlockMenu, showContextualToolbar ); + }, }; } @@ -604,7 +604,6 @@ const mapStateToProps = ( state, { uid, rootUID } ) => { meta: getEditedPostAttribute( state, 'meta' ), mode: getBlockMode( state, uid ), isSelectionEnabled: isSelectionEnabled( state ), - postType: getCurrentPostType( state ), initialPosition: getSelectedBlocksInitialCaretPosition( state ), isSelected, }; @@ -655,8 +654,7 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { } ); BlockListBlock.childContextTypes = { - BlockList: noop, - canUserUseUnfilteredHTML: noop, + createInnerBlockList: noop, }; export default compose( @@ -669,7 +667,4 @@ export default compose( }; } ), withFilters( 'editor.BlockListBlock' ), - withAPIData( ( { postType } ) => ( { - user: `/wp/v2/users/me?post_type=${ postType }&context=edit`, - } ) ), )( BlockListBlock ); diff --git a/editor/components/block-preview/index.js b/editor/components/block-preview/index.js index 7388183af11220..777df4e9e431be 100644 --- a/editor/components/block-preview/index.js +++ b/editor/components/block-preview/index.js @@ -6,11 +6,13 @@ import { noop } from 'lodash'; /** * WordPress dependencies */ +import { Component } from '@wordpress/element'; import { createBlock, BlockEdit } from '@wordpress/blocks'; /** * Internal dependencies */ +import { createInnerBlockList } from '../../utils/block-list'; import './style.scss'; /** @@ -19,22 +21,42 @@ import './style.scss'; * @param {Object} props Component props. * @return {WPElement} Rendered element. */ -function BlockPreview( { name, attributes } ) { - const block = createBlock( name, attributes ); - - return ( -
-
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 ( +
+
Preview
+
+ +
-
- ); + ); + } } +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, {} );