diff --git a/packages/reusable-blocks/src/store/actions.js b/packages/reusable-blocks/src/store/actions.js index b9642bc32be025..483a7fe6907393 100644 --- a/packages/reusable-blocks/src/store/actions.js +++ b/packages/reusable-blocks/src/store/actions.js @@ -1,20 +1,46 @@ /** - * Internal dependencies + * External dependencies */ +import { isFunction } from 'lodash'; + +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; import { - convertBlockToStatic, - convertBlocksToReusable, - deleteReusableBlock, -} from './controls'; + createBlock, + isReusableBlock, + parse, + serialize, +} from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; /** * Returns a generator converting a reusable block into a static block. * * @param {string} clientId The client ID of the block to attach. */ -export function* __experimentalConvertBlockToStatic( clientId ) { - yield convertBlockToStatic( clientId ); -} +export const __experimentalConvertBlockToStatic = ( clientId ) => ( { + registry, +} ) => { + const oldBlock = registry.select( blockEditorStore ).getBlock( clientId ); + const reusableBlock = registry + .select( 'core' ) + .getEditedEntityRecord( + 'postType', + 'wp_block', + oldBlock.attributes.ref + ); + + const newBlocks = parse( + isFunction( reusableBlock.content ) + ? reusableBlock.content( reusableBlock ) + : reusableBlock.content + ); + registry + .dispatch( blockEditorStore ) + .replaceBlocks( oldBlock.clientId, newBlocks ); +}; /** * Returns a generator converting one or more static blocks into a reusable block. @@ -22,18 +48,66 @@ export function* __experimentalConvertBlockToStatic( clientId ) { * @param {string[]} clientIds The client IDs of the block to detach. * @param {string} title Reusable block title. */ -export function* __experimentalConvertBlocksToReusable( clientIds, title ) { - yield convertBlocksToReusable( clientIds, title ); -} +export const __experimentalConvertBlocksToReusable = ( + clientIds, + title +) => async ( { registry, dispatch } ) => { + const reusableBlock = { + title: title || __( 'Untitled Reusable block' ), + content: serialize( + registry.select( blockEditorStore ).getBlocksByClientId( clientIds ) + ), + status: 'publish', + }; + + const updatedRecord = await registry + .dispatch( 'core' ) + .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); + + const newBlock = createBlock( 'core/block', { + ref: updatedRecord.id, + } ); + registry.dispatch( blockEditorStore ).replaceBlocks( clientIds, newBlock ); + dispatch.__experimentalSetEditingReusableBlock( newBlock.clientId, true ); +}; /** * Returns a generator deleting a reusable block. * * @param {string} id The ID of the reusable block to delete. */ -export function* __experimentalDeleteReusableBlock( id ) { - yield deleteReusableBlock( id ); -} +export const __experimentalDeleteReusableBlock = ( id ) => async ( { + registry, +} ) => { + const reusableBlock = registry + .select( 'core' ) + .getEditedEntityRecord( 'postType', 'wp_block', id ); + + // Don't allow a reusable block with a temporary ID to be deleted + if ( ! reusableBlock ) { + return; + } + + // Remove any other blocks that reference this reusable block + const allBlocks = registry.select( blockEditorStore ).getBlocks(); + const associatedBlocks = allBlocks.filter( + ( block ) => isReusableBlock( block ) && block.attributes.ref === id + ); + const associatedBlockClientIds = associatedBlocks.map( + ( block ) => block.clientId + ); + + // Remove the parsed block. + if ( associatedBlockClientIds.length ) { + registry + .dispatch( blockEditorStore ) + .removeBlocks( associatedBlockClientIds ); + } + + await registry + .dispatch( 'core' ) + .deleteEntityRecord( 'postType', 'wp_block', id ); +}; /** * Returns an action descriptor for SET_EDITING_REUSABLE_BLOCK action. diff --git a/packages/reusable-blocks/src/store/controls.js b/packages/reusable-blocks/src/store/controls.js deleted file mode 100644 index e430688684305a..00000000000000 --- a/packages/reusable-blocks/src/store/controls.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * External dependencies - */ -import { isFunction } from 'lodash'; - -/** - * WordPress dependencies - */ -import { - isReusableBlock, - createBlock, - parse, - serialize, -} from '@wordpress/blocks'; -import { createRegistryControl } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { store as blockEditorStore } from '@wordpress/block-editor'; - -/** - * Internal dependencies - */ -import { store as reusableBlocksStore } from './index.js'; - -/** - * Convert a reusable block to a static block effect handler - * - * @param {string} clientId Block ID. - * @return {Object} control descriptor. - */ -export function convertBlockToStatic( clientId ) { - return { - type: 'CONVERT_BLOCK_TO_STATIC', - clientId, - }; -} - -/** - * Convert a static block to a reusable block effect handler - * - * @param {Array} clientIds Block IDs. - * @param {string} title Reusable block title. - * @return {Object} control descriptor. - */ -export function convertBlocksToReusable( clientIds, title ) { - return { - type: 'CONVERT_BLOCKS_TO_REUSABLE', - clientIds, - title, - }; -} - -/** - * Deletes a reusable block. - * - * @param {string} id Reusable block ID. - * @return {Object} control descriptor. - */ -export function deleteReusableBlock( id ) { - return { - type: 'DELETE_REUSABLE_BLOCK', - id, - }; -} - -const controls = { - CONVERT_BLOCK_TO_STATIC: createRegistryControl( - ( registry ) => ( { clientId } ) => { - const oldBlock = registry - .select( blockEditorStore ) - .getBlock( clientId ); - const reusableBlock = registry - .select( 'core' ) - .getEditedEntityRecord( - 'postType', - 'wp_block', - oldBlock.attributes.ref - ); - - const newBlocks = parse( - isFunction( reusableBlock.content ) - ? reusableBlock.content( reusableBlock ) - : reusableBlock.content - ); - registry - .dispatch( blockEditorStore ) - .replaceBlocks( oldBlock.clientId, newBlocks ); - } - ), - - CONVERT_BLOCKS_TO_REUSABLE: createRegistryControl( - ( registry ) => - async function ( { clientIds, title } ) { - const reusableBlock = { - title: title || __( 'Untitled Reusable block' ), - content: serialize( - registry - .select( blockEditorStore ) - .getBlocksByClientId( clientIds ) - ), - status: 'publish', - }; - - const updatedRecord = await registry - .dispatch( 'core' ) - .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); - - const newBlock = createBlock( 'core/block', { - ref: updatedRecord.id, - } ); - registry - .dispatch( blockEditorStore ) - .replaceBlocks( clientIds, newBlock ); - registry - .dispatch( reusableBlocksStore ) - .__experimentalSetEditingReusableBlock( - newBlock.clientId, - true - ); - } - ), - - DELETE_REUSABLE_BLOCK: createRegistryControl( - ( registry ) => - async function ( { id } ) { - const reusableBlock = registry - .select( 'core' ) - .getEditedEntityRecord( 'postType', 'wp_block', id ); - - // Don't allow a reusable block with a temporary ID to be deleted - if ( ! reusableBlock ) { - return; - } - - // Remove any other blocks that reference this reusable block - const allBlocks = registry - .select( blockEditorStore ) - .getBlocks(); - const associatedBlocks = allBlocks.filter( - ( block ) => - isReusableBlock( block ) && block.attributes.ref === id - ); - const associatedBlockClientIds = associatedBlocks.map( - ( block ) => block.clientId - ); - - // Remove the parsed block. - if ( associatedBlockClientIds.length ) { - registry - .dispatch( blockEditorStore ) - .removeBlocks( associatedBlockClientIds ); - } - - await registry - .dispatch( 'core' ) - .deleteEntityRecord( 'postType', 'wp_block', id ); - } - ), -}; - -export default controls; diff --git a/packages/reusable-blocks/src/store/index.js b/packages/reusable-blocks/src/store/index.js index 55de632a9edd1d..247f2cd373ecbc 100644 --- a/packages/reusable-blocks/src/store/index.js +++ b/packages/reusable-blocks/src/store/index.js @@ -7,7 +7,6 @@ import { createReduxStore, register } from '@wordpress/data'; * Internal dependencies */ import * as actions from './actions'; -import controls from './controls'; import reducer from './reducer'; import * as selectors from './selectors'; @@ -22,9 +21,9 @@ const STORE_NAME = 'core/reusable-blocks'; */ export const store = createReduxStore( STORE_NAME, { actions, - controls, reducer, selectors, + __experimentalUseThunks: true, } ); register( store ); diff --git a/packages/reusable-blocks/src/store/test/__snapshots__/actions.js.snap b/packages/reusable-blocks/src/store/test/__snapshots__/actions.js.snap new file mode 100644 index 00000000000000..6b868bea66ed2c --- /dev/null +++ b/packages/reusable-blocks/src/store/test/__snapshots__/actions.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Actions __experimentalConvertBlockToStatic should convert a static reusable into a static block 1`] = ` +Array [ + Object { + "attributes": Object { + "name": "Big Bird", + }, + "innerBlocks": Array [ + Object { + "attributes": Object { + "name": "Oscar the Grouch", + }, + "innerBlocks": Array [], + "isValid": true, + "name": "core/test-block", + "originalContent": "", + "validationIssues": Array [], + }, + Object { + "attributes": Object { + "name": "Cookie Monster", + }, + "innerBlocks": Array [], + "isValid": true, + "name": "core/test-block", + "originalContent": "", + "validationIssues": Array [], + }, + ], + "isValid": true, + "name": "core/test-block", + "originalContent": "", + "validationIssues": Array [], + }, +] +`; + +exports[`Actions __experimentalConvertBlocksToReusable should convert a static block into a reusable block 1`] = ` +Array [ + Object { + "attributes": Object { + "ref": "new-id", + }, + "innerBlocks": Array [], + "isValid": true, + "name": "core/block", + }, +] +`; diff --git a/packages/reusable-blocks/src/store/test/actions.js b/packages/reusable-blocks/src/store/test/actions.js index 6d189c6b535f4b..b84cdac0efd8ec 100644 --- a/packages/reusable-blocks/src/store/test/actions.js +++ b/packages/reusable-blocks/src/store/test/actions.js @@ -1,17 +1,271 @@ +/** + * WordPress dependencies + */ +import { createRegistry } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { + store as blocksStore, + createBlock, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; + +import { store as coreStore } from '@wordpress/core-data'; +import apiFetch from '@wordpress/api-fetch'; + /** * Internal dependencies */ -import { __experimentalSetEditingReusableBlock } from '../actions'; +import { store as reusableBlocksStore } from '../index'; + +jest.mock( '@wordpress/api-fetch', () => ( { + __esModule: true, + default: jest.fn(), +} ) ); + +function createRegistryWithStores() { + // create a registry and register stores + const registry = createRegistry(); + + registry.register( coreStore ); + registry.register( blockEditorStore ); + registry.register( reusableBlocksStore ); + registry.register( blocksStore ); + + // Register entity here instead of mocking API handlers for loadPostTypeEntities() + registry.dispatch( coreStore ).addEntities( [ + { + baseURL: '/wp/v2/reusable-blocks', + kind: 'postType', + name: 'wp_block', + label: 'Reusable blocks', + }, + ] ); + + return registry; +} describe( 'Actions', () => { + beforeAll( () => { + registerBlockType( 'core/test-block', { + title: 'Test block', + category: 'text', + save: () => null, + attributes: { + name: { type: 'string' }, + }, + } ); + + registerBlockType( 'core/block', { + title: 'Reusable block', + category: 'text', + save: () => null, + attributes: { + ref: { type: 'string' }, + }, + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-block' ); + unregisterBlockType( 'core/block' ); + } ); + describe( '__experimentalSetEditingReusableBlock', () => { - it( 'should return the SET_EDITING_REUSABLE_BLOCK action', () => { - const result = __experimentalSetEditingReusableBlock( 3, true ); - expect( result ).toEqual( { - type: 'SET_EDITING_REUSABLE_BLOCK', - clientId: 3, - isEditing: true, + it( 'should flip the editing state', () => { + const registry = createRegistryWithStores(); + + registry + .dispatch( reusableBlocksStore ) + .__experimentalSetEditingReusableBlock( 3, true ); + expect( + registry + .select( reusableBlocksStore ) + .__experimentalIsEditingReusableBlock( 3 ) + ).toBe( true ); + + registry + .dispatch( reusableBlocksStore ) + .__experimentalSetEditingReusableBlock( 3, false ); + expect( + registry + .select( reusableBlocksStore ) + .__experimentalIsEditingReusableBlock( 3 ) + ).toBe( false ); + } ); + } ); + + describe( '__experimentalConvertBlocksToReusable', () => { + it( 'should convert a static block into a reusable block', async () => { + const staticBlock = createBlock( 'core/test-block', { + name: 'Big Bird', } ); + + const registry = createRegistryWithStores(); + // mock the api-fetch + apiFetch.mockImplementation( async ( args ) => { + const { path, data } = args; + switch ( path ) { + case '/wp/v2/reusable-blocks': + return { + id: 'new-id', + ...data, + }; + default: + throw new Error( `unexpected API endpoint: ${ path }` ); + } + } ); + + registry.dispatch( blockEditorStore ).insertBlock( staticBlock ); + + await registry + .dispatch( reusableBlocksStore ) + .__experimentalConvertBlocksToReusable( [ + staticBlock.clientId, + ] ); + + // check that blocks were converted to reusable + const updatedBlocks = registry + .select( blockEditorStore ) + .getBlocks(); + + const newClientId = updatedBlocks[ 0 ].clientId; + + expect( updatedBlocks ).toHaveLength( 1 ); + + // Delete random clientId before matching with snapshot + delete updatedBlocks[ 0 ].clientId; + expect( updatedBlocks ).toMatchSnapshot(); + + expect( + registry + .select( reusableBlocksStore ) + .__experimentalIsEditingReusableBlock( newClientId ) + ).toBe( true ); + } ); + } ); + + describe( '__experimentalConvertBlockToStatic', () => { + it( 'should convert a static reusable into a static block', async () => { + const associatedBlock = createBlock( 'core/block', { + ref: 123, + } ); + const reusableBlock = { + id: 123, + title: 'My cool block', + content: + '', + }; + + const registry = createRegistryWithStores(); + // mock the api-fetch + apiFetch.mockImplementation( async ( args ) => { + const { path, data } = args; + switch ( path ) { + case '/wp/v2/reusable-blocks/123': + return data; + default: + throw new Error( `unexpected API endpoint: ${ path }` ); + } + } ); + + registry + .dispatch( blockEditorStore ) + .insertBlock( associatedBlock ); + await registry + .dispatch( coreStore ) + .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); + + await registry + .dispatch( reusableBlocksStore ) + .__experimentalConvertBlockToStatic( [ + associatedBlock.clientId, + ] ); + + // check that blocks were converted to reusable + const updatedBlocks = registry + .select( blockEditorStore ) + .getBlocks(); + + expect( updatedBlocks ).toHaveLength( 1 ); + + // Delete random clientIds before matching with snapshot + delete updatedBlocks[ 0 ].clientId; + delete updatedBlocks[ 0 ].innerBlocks[ 0 ].clientId; + delete updatedBlocks[ 0 ].innerBlocks[ 1 ].clientId; + expect( updatedBlocks ).toMatchSnapshot(); + } ); + } ); + + describe( '__experimentalDeleteReusableBlock', () => { + it( 'should delete a reusable block and remove all its instances from the store', async () => { + const reusableBlock = { + id: 123, + }; + const availableBlocks = [ + createBlock( 'core/block' ), + createBlock( 'core/block', { + ref: 123, + } ), + createBlock( 'core/block', { + ref: 456, + } ), + createBlock( 'core/block', { + ref: 123, + } ), + createBlock( 'core/test-block', { + ref: 123, + } ), + ]; + + const registry = createRegistryWithStores(); + // mock the api-fetch + apiFetch.mockImplementation( async ( args ) => { + const { path, data, method } = args; + if ( + path.startsWith( '/wp/v2/reusable-blocks' ) && + method === 'DELETE' + ) { + return data; + } else if ( + path === '/wp/v2/reusable-blocks/123' && + method === 'PUT' + ) { + return data; + } + throw new Error( + `unexpected API request: ${ path } ${ method }` + ); + } ); + + await registry + .dispatch( coreStore ) + .saveEntityRecord( 'postType', 'wp_block', reusableBlock ); + + registry + .dispatch( blockEditorStore ) + .insertBlocks( availableBlocks ); + + // confirm that reusable block is stored + const reusableBlockBefore = registry + .select( coreStore ) + .getEntityRecord( 'postType', 'wp_block', reusableBlock.id ); + + expect( reusableBlockBefore ).toBeTruthy(); + + await registry + .dispatch( reusableBlocksStore ) + .__experimentalDeleteReusableBlock( reusableBlock.id ); + + // check if reusable block was deleted + const reusableBlockAfter = registry + .select( coreStore ) + .getEntityRecord( 'postType', 'wp_block', reusableBlock.id ); + expect( reusableBlockAfter ).toBeFalsy(); + + // check if block instances were removed from the editor + const blocksAfter = registry.select( blockEditorStore ).getBlocks(); + expect( blocksAfter ).toHaveLength( 3 ); } ); } ); } ); diff --git a/packages/reusable-blocks/src/store/test/controls.js b/packages/reusable-blocks/src/store/test/controls.js deleted file mode 100644 index 4f51b737af9017..00000000000000 --- a/packages/reusable-blocks/src/store/test/controls.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - * WordPress dependencies - */ -import { - registerBlockType, - unregisterBlockType, - createBlock, -} from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import controls from '../controls'; - -const { - CONVERT_BLOCK_TO_STATIC, - CONVERT_BLOCKS_TO_REUSABLE, - DELETE_REUSABLE_BLOCK, -} = controls; - -describe( 'reusable blocks effects', () => { - beforeAll( () => { - registerBlockType( 'core/test-block', { - title: 'Test block', - category: 'text', - save: () => null, - attributes: { - name: { type: 'string' }, - }, - } ); - - registerBlockType( 'core/block', { - title: 'Reusable block', - category: 'text', - save: () => null, - attributes: { - ref: { type: 'string' }, - }, - } ); - } ); - - afterAll( () => { - unregisterBlockType( 'core/test-block' ); - unregisterBlockType( 'core/block' ); - } ); - - describe( 'CONVERT_BLOCKS_TO_REUSABLE', () => { - it( 'should convert a static block into a reusable block', async () => { - const staticBlock = createBlock( 'core/test-block', { - name: 'Big Bird', - } ); - const saveEntityRecord = jest.fn( () => ( { id: 456 } ) ); - const replaceBlocks = jest.fn(); - const __experimentalSetEditingReusableBlock = jest.fn(); - const getBlocksByClientId = jest.fn( () => [ staticBlock ] ); - const registry = { - select: jest.fn( () => ( { - getBlocksByClientId, - } ) ), - dispatch: jest.fn( () => ( { - saveEntityRecord, - replaceBlocks, - __experimentalSetEditingReusableBlock, - } ) ), - }; - - await CONVERT_BLOCKS_TO_REUSABLE( registry )( { - clientIds: [ staticBlock.clientId ], - } ); - - expect( saveEntityRecord ).toHaveBeenCalledWith( - 'postType', - 'wp_block', - expect.objectContaining( { - content: '', - status: 'publish', - title: 'Untitled Reusable block', - } ) - ); - expect( replaceBlocks ).toHaveBeenCalledWith( - [ staticBlock.clientId ], - expect.objectContaining( { - attributes: expect.objectContaining( { ref: 456 } ), - isValid: true, - name: 'core/block', - } ) - ); - expect( __experimentalSetEditingReusableBlock ).toHaveBeenCalled(); - } ); - - describe( 'CONVERT_BLOCK_TO_STATIC', () => { - it( 'should convert a reusable block into a static block', async () => { - const associatedBlock = createBlock( 'core/block', { - ref: 123, - } ); - const reusableBlock = { - id: 123, - title: 'My cool block', - content: - '', - }; - const replaceBlocks = jest.fn(); - const getBlock = jest.fn( () => associatedBlock ); - const getEditedEntityRecord = jest.fn( () => reusableBlock ); - const registry = { - select: jest.fn( () => ( { - getBlock, - getEditedEntityRecord, - } ) ), - dispatch: jest.fn( () => ( { - replaceBlocks, - } ) ), - }; - - CONVERT_BLOCK_TO_STATIC( registry )( { - clientId: associatedBlock.clientId, - } ); - - expect( replaceBlocks ).toHaveBeenCalledWith( - associatedBlock.clientId, - [ - expect.objectContaining( { - name: 'core/test-block', - attributes: { name: 'Big Bird' }, - innerBlocks: [ - expect.objectContaining( { - attributes: { name: 'Oscar the Grouch' }, - } ), - expect.objectContaining( { - attributes: { name: 'Cookie Monster' }, - } ), - ], - } ), - ] - ); - } ); - } ); - - describe( 'DELETE_REUSABLE_BLOCK', () => { - it( 'should delete a reusable block and remove all its instances from the store', async () => { - const associatedBlock = createBlock( 'core/block', { - ref: 123, - } ); - const reusableBlock = { - id: 123, - }; - const availableBlocks = [ - createBlock( 'core/block' ), - createBlock( 'core/block', { - ref: 123, - } ), - createBlock( 'core/block', { - ref: 456, - } ), - createBlock( 'core/block', { - ref: 123, - } ), - createBlock( 'core/test-block', { - ref: 123, - } ), - ]; - const removeBlocks = jest.fn(); - const deleteEntityRecord = jest.fn(); - const getBlock = jest.fn( () => associatedBlock ); - const getBlocks = jest.fn( () => availableBlocks ); - const getEditedEntityRecord = jest.fn( () => reusableBlock ); - const registry = { - select: jest.fn( () => ( { - getBlock, - getBlocks, - getEditedEntityRecord, - } ) ), - dispatch: jest.fn( () => ( { - removeBlocks, - deleteEntityRecord, - } ) ), - }; - - DELETE_REUSABLE_BLOCK( registry )( { - id: reusableBlock.id, - } ); - - expect( deleteEntityRecord ).toHaveBeenCalledWith( - 'postType', - 'wp_block', - 123 - ); - expect( removeBlocks ).toHaveBeenCalledWith( [ - availableBlocks[ 1 ].clientId, - availableBlocks[ 3 ].clientId, - ] ); - } ); - } ); - } ); -} );