diff --git a/packages/block-directory/src/components/auto-block-uninstaller/index.js b/packages/block-directory/src/components/auto-block-uninstaller/index.js new file mode 100644 index 00000000000000..c7e804f9f09233 --- /dev/null +++ b/packages/block-directory/src/components/auto-block-uninstaller/index.js @@ -0,0 +1,30 @@ +/** + * WordPress dependencies + */ +import { unregisterBlockType } from '@wordpress/blocks'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect } from '@wordpress/element'; + +export default function AutoBlockUninstaller() { + const { uninstallBlockType } = useDispatch( 'core/block-directory' ); + + const shouldRemoveBlockTypes = useSelect( ( select ) => { + const { isAutosavingPost, isSavingPost } = select( 'core/editor' ); + return isSavingPost() && ! isAutosavingPost(); + } ); + + const unusedBlockTypes = useSelect( ( select ) => + select( 'core/block-directory' ).getUnusedBlockTypes() + ); + + useEffect( () => { + if ( shouldRemoveBlockTypes && unusedBlockTypes.length ) { + unusedBlockTypes.forEach( ( blockType ) => { + uninstallBlockType( blockType ); + unregisterBlockType( blockType.name ); + } ); + } + }, [ shouldRemoveBlockTypes ] ); + + return null; +} diff --git a/packages/block-directory/src/plugins/index.js b/packages/block-directory/src/plugins/index.js index f1e013a8d0ddee..c35f5d811b2768 100644 --- a/packages/block-directory/src/plugins/index.js +++ b/packages/block-directory/src/plugins/index.js @@ -6,6 +6,7 @@ import { registerPlugin } from '@wordpress/plugins'; /** * Internal dependencies */ +import AutoBlockUninstaller from '../components/auto-block-uninstaller'; import InserterMenuDownloadableBlocksPanel from './inserter-menu-downloadable-blocks-panel'; import InstalledBlocksPrePublishPanel from './installed-blocks-pre-publish-panel'; @@ -13,6 +14,7 @@ registerPlugin( 'block-directory', { render() { return ( <> + diff --git a/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js b/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js index 0b32c1ee29bc62..c3c2ca5925933e 100644 --- a/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js +++ b/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js @@ -12,7 +12,7 @@ import CompactList from '../../components/compact-list'; export default function InstalledBlocksPrePublishPanel() { const newBlockTypes = useSelect( ( select ) => - select( 'core/block-directory' ).getInstalledBlockTypes() + select( 'core/block-directory' ).getNewBlockTypes() ); if ( ! newBlockTypes.length ) { diff --git a/packages/block-directory/src/store/actions.js b/packages/block-directory/src/store/actions.js index bcbed555d16a2c..beea3d05ab01e9 100644 --- a/packages/block-directory/src/store/actions.js +++ b/packages/block-directory/src/store/actions.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { apiFetch, select } from '@wordpress/data-controls'; +import { apiFetch, dispatch, select } from '@wordpress/data-controls'; /** * Internal dependencies @@ -91,6 +91,34 @@ export function* installBlockType( block ) { return success; } +/** + * Action triggered to uninstall a block plugin. + * + * @param {Object} block The blockType object. + */ +export function* uninstallBlockType( block ) { + const { id } = block; + try { + const response = yield apiFetch( { + path: '__experimental/block-directory/uninstall', + data: { + slug: id, + }, + method: 'DELETE', + } ); + if ( response !== true ) { + throw new Error( __( 'Unable to uninstall this block.' ) ); + } + yield removeInstalledBlockType( block ); + } catch ( error ) { + yield dispatch( + 'core/notices', + 'createErrorNotice', + error.message || __( 'An error occurred.' ) + ); + } +} + /** * Returns an action object used to add a newly installed block type. * @@ -105,6 +133,20 @@ export function addInstalledBlockType( item ) { }; } +/** + * Returns an action object used to remove a newly installed block type. + * + * @param {string} item The block item with the block id and name. + * + * @return {Object} Action object. + */ +export function removeInstalledBlockType( item ) { + return { + type: 'REMOVE_INSTALLED_BLOCK_TYPE', + item, + }; +} + /** * Returns an action object used to indicate install in progress * diff --git a/packages/block-directory/src/store/selectors.js b/packages/block-directory/src/store/selectors.js index bf472cd727dc3e..81d6e8ed89c1ed 100644 --- a/packages/block-directory/src/store/selectors.js +++ b/packages/block-directory/src/store/selectors.js @@ -1,3 +1,13 @@ +/** + * WordPress dependencies + */ +import { createRegistrySelector } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import hasBlockType from './utils/has-block-type'; + /** * Returns true if application is requesting for downloadable blocks. * @@ -46,6 +56,54 @@ export function getInstalledBlockTypes( state ) { return state.blockManagement.installedBlockTypes; } +/** + * Returns block types that have been installed on the server and used in the + * current post. + * + * @param {Object} state Global application state. + * + * @return {Array} Block type items. + */ +export const getNewBlockTypes = createRegistrySelector( + ( select ) => ( state ) => { + const usedBlockTree = select( 'core/block-editor' ).getBlocks(); + const installedBlockTypes = getInstalledBlockTypes( state ); + + const newBlockTypes = []; + installedBlockTypes.forEach( ( blockType ) => { + if ( hasBlockType( blockType, usedBlockTree ) ) { + newBlockTypes.push( blockType ); + } + } ); + + return newBlockTypes; + } +); + +/** + * Returns the block types that have been installed on the server but are not + * used in the current post. + * + * @param {Object} state Global application state. + * + * @return {Array} Block type items. + */ +export const getUnusedBlockTypes = createRegistrySelector( + ( select ) => ( state ) => { + const usedBlockTree = select( 'core/block-editor' ).getBlocks(); + const installedBlockTypes = getInstalledBlockTypes( state ); + + const newBlockTypes = []; + installedBlockTypes.forEach( ( blockType ) => { + if ( ! hasBlockType( blockType, usedBlockTree ) ) { + newBlockTypes.push( blockType ); + } + } ); + + return newBlockTypes; + } +); + /** * Returns true if application is calling install endpoint. * diff --git a/packages/block-directory/src/store/test/actions.js b/packages/block-directory/src/store/test/actions.js index b51301fc904c47..eaac933fb0a65c 100644 --- a/packages/block-directory/src/store/test/actions.js +++ b/packages/block-directory/src/store/test/actions.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { installBlockType } from '../actions'; +import { installBlockType, uninstallBlockType } from '../actions'; describe( 'actions', () => { const item = { @@ -126,4 +126,48 @@ describe( 'actions', () => { } ); } ); } ); + + describe( 'uninstallBlockType', () => { + it( 'should uninstall a block successfully', () => { + const generator = uninstallBlockType( item ); + + expect( generator.next().value ).toMatchObject( { + type: 'API_FETCH', + } ); + + expect( generator.next( true ).value ).toEqual( { + type: 'REMOVE_INSTALLED_BLOCK_TYPE', + item, + } ); + + expect( generator.next() ).toEqual( { + value: undefined, + done: true, + } ); + } ); + + it( "should set a global notice if the plugin can't uninstall", () => { + const generator = uninstallBlockType( item ); + + expect( generator.next().value ).toMatchObject( { + type: 'API_FETCH', + } ); + + const apiError = { + code: 'could_not_remove_plugin', + message: 'Could not fully remove the plugin .', + data: null, + }; + expect( generator.next( apiError ).value ).toMatchObject( { + type: 'DISPATCH', + actionName: 'createErrorNotice', + storeKey: 'core/notices', + } ); + + expect( generator.next() ).toEqual( { + value: undefined, + done: true, + } ); + } ); + } ); } ); diff --git a/packages/block-directory/src/store/test/fixtures/index.js b/packages/block-directory/src/store/test/fixtures/index.js index 0f7cc95dc8a6b4..29ff9d8f071c8e 100644 --- a/packages/block-directory/src/store/test/fixtures/index.js +++ b/packages/block-directory/src/store/test/fixtures/index.js @@ -18,7 +18,33 @@ export const downloadableBlock = { humanizedUpdated: '3 months ago', }; -export const installedItem = { +export const blockTypeInstalled = { id: 'boxer-block', name: 'boxer/boxer', }; + +export const blockTypeUnused = { + id: 'example-block', + name: 'fake/unused', +}; + +export const blockList = [ + { + clientId: 1, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + }, + { + clientId: 2, + name: 'boxer/boxer', + attributes: {}, + innerBlocks: [], + }, + { + clientId: 3, + name: 'core/heading', + attributes: {}, + innerBlocks: [], + }, +]; diff --git a/packages/block-directory/src/store/test/reducer.js b/packages/block-directory/src/store/test/reducer.js index fa7c55251b3d6b..897217ea0f85b4 100644 --- a/packages/block-directory/src/store/test/reducer.js +++ b/packages/block-directory/src/store/test/reducer.js @@ -12,7 +12,7 @@ import { errorNotices, hasPermission, } from '../reducer'; -import { installedItem, downloadableBlock } from './fixtures'; +import { blockTypeInstalled, downloadableBlock } from './fixtures'; describe( 'state', () => { describe( 'downloadableBlocks()', () => { @@ -72,7 +72,7 @@ describe( 'state', () => { const initialState = deepFreeze( { installedBlockTypes: [] } ); const state = blockManagement( initialState, { type: 'ADD_INSTALLED_BLOCK_TYPE', - item: installedItem, + item: blockTypeInstalled, } ); expect( state.installedBlockTypes ).toHaveLength( 1 ); @@ -80,11 +80,11 @@ describe( 'state', () => { it( 'should remove item from the installedBlockTypesList', () => { const initialState = deepFreeze( { - installedBlockTypes: [ installedItem ], + installedBlockTypes: [ blockTypeInstalled ], } ); const state = blockManagement( initialState, { type: 'REMOVE_INSTALLED_BLOCK_TYPE', - item: installedItem, + item: blockTypeInstalled, } ); expect( state.installedBlockTypes ).toHaveLength( 0 ); diff --git a/packages/block-directory/src/store/test/selectors.js b/packages/block-directory/src/store/test/selectors.js index fc702d93c1d26d..690bec529018a8 100644 --- a/packages/block-directory/src/store/test/selectors.js +++ b/packages/block-directory/src/store/test/selectors.js @@ -1,12 +1,19 @@ /** * Internal dependencies */ -import { downloadableBlock } from './fixtures'; +import { + blockList, + blockTypeInstalled, + blockTypeUnused, + downloadableBlock, +} from './fixtures'; import { getDownloadableBlocks, getErrorNotices, getErrorNoticeForBlock, getInstalledBlockTypes, + getNewBlockTypes, + getUnusedBlockTypes, isInstalling, } from '../selectors'; @@ -24,6 +31,76 @@ describe( 'selectors', () => { } ); } ); + describe( 'getNewBlockTypes', () => { + it( 'should retrieve the block types that are installed and in the post content', () => { + getNewBlockTypes.registry = { + select: jest.fn( () => ( { getBlocks: () => blockList } ) ), + }; + const state = { + blockManagement: { + installedBlockTypes: [ + blockTypeInstalled, + blockTypeUnused, + ], + }, + }; + const blockTypes = getNewBlockTypes( state ); + expect( blockTypes ).toHaveLength( 1 ); + expect( blockTypes[ 0 ] ).toEqual( blockTypeInstalled ); + } ); + + it( 'should return an empty array if no blocks are used', () => { + getNewBlockTypes.registry = { + select: jest.fn( () => ( { getBlocks: () => [] } ) ), + }; + const state = { + blockManagement: { + installedBlockTypes: [ + blockTypeInstalled, + blockTypeUnused, + ], + }, + }; + const blockTypes = getNewBlockTypes( state ); + expect( blockTypes ).toHaveLength( 0 ); + } ); + } ); + + describe( 'getUnusedBlockTypes', () => { + it( 'should retrieve the block types that are installed but not used', () => { + getUnusedBlockTypes.registry = { + select: jest.fn( () => ( { getBlocks: () => blockList } ) ), + }; + const state = { + blockManagement: { + installedBlockTypes: [ + blockTypeInstalled, + blockTypeUnused, + ], + }, + }; + const blockTypes = getUnusedBlockTypes( state ); + expect( blockTypes ).toHaveLength( 1 ); + expect( blockTypes[ 0 ] ).toEqual( blockTypeUnused ); + } ); + + it( 'should return all block types if no blocks are used', () => { + getUnusedBlockTypes.registry = { + select: jest.fn( () => ( { getBlocks: () => [] } ) ), + }; + const state = { + blockManagement: { + installedBlockTypes: [ + blockTypeInstalled, + blockTypeUnused, + ], + }, + }; + const blockTypes = getUnusedBlockTypes( state ); + expect( blockTypes ).toHaveLength( 2 ); + } ); + } ); + describe( 'getErrorNotices', () => { const state = { errorNotices: { diff --git a/packages/block-directory/src/store/utils/has-block-type.js b/packages/block-directory/src/store/utils/has-block-type.js new file mode 100644 index 00000000000000..28274cbd04eece --- /dev/null +++ b/packages/block-directory/src/store/utils/has-block-type.js @@ -0,0 +1,24 @@ +/** + * Check if a block list contains a specific block type. Recursively searches + * through `innerBlocks` if they exist. + * + * @param {Object} blockType A block object to search for. + * @param {Object[]} blocks The list of blocks to look through. + * + * @return {boolean} Whether the blockType is found. + */ +export default function hasBlockType( blockType, blocks = [] ) { + if ( ! blocks.length ) { + return false; + } + if ( blocks.some( ( { name } ) => name === blockType.name ) ) { + return true; + } + for ( let i = 0; i < blocks.length; i++ ) { + if ( hasBlockType( blockType, blocks[ i ].innerBlocks ) ) { + return true; + } + } + + return false; +} diff --git a/packages/block-directory/src/store/utils/test/has-block-type.js b/packages/block-directory/src/store/utils/test/has-block-type.js new file mode 100644 index 00000000000000..b8104b59d54bf9 --- /dev/null +++ b/packages/block-directory/src/store/utils/test/has-block-type.js @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { + blockList, + blockTypeInstalled, + blockTypeUnused, +} from '../../test/fixtures'; +import hasBlockType from '../has-block-type'; + +describe( 'hasBlockType', () => { + it( 'should find the block', () => { + const found = hasBlockType( blockTypeInstalled, blockList ); + expect( found ).toBe( true ); + } ); + + it( 'should not find the unused block', () => { + const found = hasBlockType( blockTypeUnused, blockList ); + expect( found ).toBe( false ); + } ); + + it( 'should find the block in innerBlocks', () => { + const innerBlockList = [ + ...blockList, + { + clientId: 4, + name: 'core/cover', + attributes: {}, + innerBlocks: [ + { + clientId: 5, + name: blockTypeUnused.name, + attributes: {}, + innerBlocks: [], + }, + ], + }, + ]; + const found = hasBlockType( blockTypeUnused, innerBlockList ); + expect( found ).toBe( true ); + } ); +} );